Skip to content

Routing

Vue Router is the core navigation system in a Vue 3 application. The following guidelines reflect how we set up and use routes across this repo.

Router Configuration

  • Keep the router instance in src/router/.
  • Export it as the default export so that main.ts can import it directly.
  • Use history mode (createWebHistory) for clean URLs (no hash).
  • Register global navigation guards in this file; keep guard logic in separate modules if they grow large.
  • Mature-hybrid exception: A legacy or server-rendered application may import feature route modules directly from src/router/index.ts instead of merging them through src/router/routes.ts. It may also keep eager bootstrap, shell, layout, and bridge components in the router entry point, and keep named guards in one guards.ts module. Use this topology only when the application must bridge Vue routes with server-owned routes. Keep the greenfield topology above as the default for new applications.
  • Treat the router default export as a scoped exception to the named-export rule in docs/conventions/01.Naming.md. The default export lets the application entry point import the configured router directly. Keep other router utilities and route collections named unless this guide says otherwise.
ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import routes from './routes'

const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_PUBLIC_PATH),
  routes,
})

export default router

Defining Routes

  • Store route definitions in src/router/routes.ts as an array of RouteRecordRaw.

  • Give each module its own src/router/routes/[module].ts file (camelCase name).

    • Export the module routes as the default export, then import them in routes.ts and spread them into the main routes array.
    ts
    // src/router/routes/auth.ts
    import type { RouteRecordRaw } from 'vue-router'
    import { ROUTE_NAMES } from '@/config/routes/names'
    
    const authRoutes: RouteRecordRaw[] = [
      {
        path: '/login',
        name: ROUTE_NAMES.AUTH_LOGIN,
        component: () => import('@/pages/auth/LoginPage.vue'),
      },
    ]
    
    export default authRoutes
  • Use component lazy-loading (dynamic import()) for every ordinary page boundary to keep the initial bundle small. Eagerly import a shell, bootstrap component, authentication callback, layout, or Vue/server bridge when it must be available during application startup or a cross-application handoff. Do not make every component lazy when the boundary itself must load before navigation can proceed.

  • Keep path names kebab-case and nested routes inside a parent if they belong to the same feature folder.

  • Declare meta properties (requiresAuth, title, etc.) on route objects; this keeps guard logic in one place.

  • Extend Vue Router's RouteMeta type to document approved meta flags and catch typos at compile time.

  • Prefer redirect: { name: ROUTE_NAMES.SOME_ROUTE } instead of hard-coded paths so route URLs can change without breaking navigation. A named redirect is a preference, not a requirement: retain a path redirect when it targets a server-owned route, a legacy URL, or a deliberate external handoff.

Route Page Components

  • Mirror the naming rules from the naming convention guide (docs/conventions/01.Naming.md).
    • Page files live in src/pages/<Feature>/<Feature>.vue (PascalCase) and must communicate the real feature/module.
    • Avoid placeholders such as Index.vue or suffixes like Page.vue; the filename should match the component imported by the route.
  • Ensure the route component imports use the matching PascalCase component filenames.

✅ Good

text
src/pages/Checkout/Checkout.vue
src/pages/UserProfile/UserProfile.vue

❌ Bad

text
src/pages/Checkout/Index.vue
src/pages/user-profile/UserProfilePage.vue

Routes configuration

ts
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router'
import authRoutes from './routes/auth'
import profileRoutes from './routes/profile'
import errorRoutes from './routes/errors'
import { ROUTE_NAMES } from '@/config/routes/names'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: ROUTE_NAMES.ROOT_HOME,
    component: () => import('@/pages/Home.vue'),
    meta: { title: 'Home' },
  },
  {
    path: '/about',
    name: ROUTE_NAMES.ROOT_ABOUT,
    component: () => import('@/pages/About.vue'),
    meta: { title: 'About' },
  },
  ...authRoutes,
  ...profileRoutes,
  ...errorRoutes,
  {
    path: '/:pathMatch(.*)*',
    redirect: { name: ROUTE_NAMES.ERROR_NOT_FOUND },
  },
]

export default routes

Nested Routes with Shared Guards

  • A parent route can carry guards and meta that every child route inherits.
  • Put shared access-control and common data-fetch on the parent. Keep child routes focused on their own page.
  • The parent's beforeEnter runs when navigation enters the parent route record, before the child route resolves.
  • It does not rerun for child-to-child navigation or parameter, query, or hash updates while the parent remains active, unless the navigation enters the parent route record again. Use a global guard, a child guard, or an in-component update guard when that transition needs a check.

For company-scoped or similarly nested features, keep the scope in the parent path and spread feature routes into its children array:

ts
const routes: RouteRecordRaw[] = [
  {
    path: '/c/:company',
    component: () => import('@/pages/Company/Company.vue'),
    children: [...settingsRoutes, ...ordersRoutes],
  },
]
ts
// src/router/routes.ts
import { authGuard, fetchCommonData } from '@/router/guards'

const routes: RouteRecordRaw[] = [
  {
    path: '/c/:company',
    component: () => import('@/pages/Company/Company.vue'),
    beforeEnter: [authGuard, fetchCommonData],
    children: [
      ...companyRoutes,
      ...settingsRoutes,
      ...orderRoutes,
    ],
  },
]

Routing Names

  • Route names are defined as constants in the src/config/routes folder, grouped by module.
    • Example: src/config/routes/auth.ts
    • For application level related routes, use src/config/routes/root.ts
  • Each module file exports a screaming-case constant named [MODULE]_ROUTE_NAMES. Use as const to ensure there will be no unwanted mutations on the constant.
  • The constant is an object where each key is the route name in screaming case and the value is the kebab-case route-name value used in Vue Router. The value is a name, not a URL path, even when both use kebab-case.
  • The keys need not match the values; this allows semantic naming independent of URL shape. Keep URL paths in the route record's path field.
  • Prefix each key with the module name to prevent collisions ([MODULE]_[ROUTE NAME]), and mirror that prefix in the value in kebab-case, so final route names stay unique when merged.
ts
// src/config/routes/auth.ts
export const AUTH_ROUTE_NAMES = {
  AUTH_LOGIN: 'auth-login',
  AUTH_REGISTER: 'auth-register',
  AUTH_FORGOT_PASSWORD: 'auth-forgot-password',
  AUTH_RESET_PASSWORD: 'auth-reset-password',
} as const

Child Modules

Keep the module constant flat—no nested objects. For submodules, extend the prefix ([MODULE]_[SUB MODULE]_[ROUTE NAME]) and reflect it in the value in kebab-case.

ts

// ✅ Good: flat keys and values include module/submodule prefixes
export const AUTH_ADMIN_ROUTE_NAMES = {
  AUTH_ADMIN_DASHBOARD: 'auth-admin-dashboard',
  AUTH_ADMIN_USERS_LIST: 'auth-admin-users-list',
} as const

// ❌ Bad: nesting even though keys/values follow the convention
// Reason it's bad: the constant must stay flat; nesting complicates imports/merges.
export const AUTH_ADMIN_ROUTE_NAMES = {
  AUTH_ADMIN_DASHBOARD: 'auth-admin-dashboard',
  AUTH_ADMIN_USERS: {
    AUTH_ADMIN_USERS_LIST: 'auth-admin-users-list',
    AUTH_ADMIN_USERS_DETAIL: 'auth-admin-users-detail',
  },
} as const

Merging Route Name Constants

  • Each module exports its own [MODULE]_ROUTE_NAMES. A barrel file spreads them into one ROUTE_NAMES object that the rest of the app imports.
  • Spreading keeps the constant flat; module prefixes in keys guarantee no collisions.
ts
// src/config/routes/names.ts
import { AUTH_ROUTE_NAMES } from './auth'
import { ROOT_ROUTE_NAMES } from './root'
import { ERROR_ROUTE_NAMES } from './errors'

export const ROUTE_NAMES = {
  ...ROOT_ROUTE_NAMES,
  ...AUTH_ROUTE_NAMES,
  ...ERROR_ROUTE_NAMES,
} as const
  • Place every guard under src/router/guards/<intent>.ts, one guard per file.
  • Name the file after the guard intent in camelCase without the Guard suffix (auth.ts, metaTitle.ts). Prefer single-word intents when possible and ensure the name communicates the guard's responsibility.
  • Default-export each guard; avoid named exports unless multiple exports are truly required.
  • Name the guard function after the intent and suffix it with Guard (e.g., authGuard, metaTitleGuard).
  • Keep guards focused on allowing, blocking, or redirecting navigation; push data preloading or expensive async work to global resolve guards.
  • Typical use cases:
    • Authentication guard – run before each navigation to check requiresAuth meta and redirect to /login if not authenticated.
    • Meta-title guard – set document.title after every successful route change.
  • Keep the lifecycle distinction clear: use beforeEach for application-wide blocking or redirecting checks, beforeEnter for route-record checks, beforeResolve for checks or preparation that must run after async route components and in-component guards, and afterEach for confirmed-navigation side effects.
  • Do not use afterEach to block navigation. Its work must tolerate a confirmed route and must not be required for the navigation to succeed. Handle errors from analytics, title updates, notifications, or similar side effects without turning them into navigation failures.

Example of a guard function

ts
// src/router/guards/auth.ts
import type { NavigationGuard } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'

const authGuard: NavigationGuard = (to, _from, next) => {
  const store = useAuthStore()
  if (to.meta.requiresAuth && !store.profile) {
    return next('/login')
  }
  next()
}

export default authGuard

Adding guard to the router instance.

Attach guards with the lifecycle that matches their intent: beforeEach for blocking/redirecting logic (most of our guards), and afterEach for side effects that need the confirmed navigation. In practice, the majority of guards should be registered via beforeEach.

ts
// src/router/index.ts
import authGuard from './guards/auth'

// ...
router.beforeEach(authGuard)
// ...

export default router

Permissions

  • Declare permissions on route.meta.permissions as an array. A single global guard evaluates them. Route authors declare permissions; they do not write guard logic.
  • Register the permissions guard with beforeResolve, not beforeEach, when its evaluator depends on data loaded by earlier guards or route resolution. beforeResolve runs later in the lifecycle, but it does not guarantee that an application has loaded every required store. The earlier guard or resolver must explicitly load that data. Keep access-control that needs no async data in beforeEach.
  • The guard flattens permissions from to.matched—the array of matched route records from parent to child. A permission on a parent route applies to every child route. This is what makes the declarative approach work for nested routes.

Define the rule and permission types once, alongside the router type augmentation:

ts
// src/types/router.d.ts
import type { RouteRecordRedirectOption } from 'vue-router'

export interface PermissionContext {
  user: { permissions: Record<string, boolean>; featureFlags: Record<string, boolean> }
  userActiveCompany: { staffIsAdmin: boolean; staffId?: string } | undefined
  activeCompany: { buyer?: boolean; seller?: boolean } | undefined
}

export type PermissionRule = string | ((ctx: PermissionContext) => boolean)

export interface RoutePermission {
  rules: PermissionRule[]
  redirect?: RouteRecordRedirectOption
}

// Adapt the generic route contract to a project-specific evaluator.
export type PermissionEvaluator = (rule: PermissionRule, ctx: PermissionContext) => boolean

Put the runtime adapter in a .ts module, not in the declaration file:

ts
// src/router/permissions.ts
import type { PermissionEvaluator } from '@/types/router'

export const evaluatePermission: PermissionEvaluator = (rule, ctx) => {
  if (typeof rule === 'function') return rule(ctx)
  return Boolean(ctx.user.permissions[rule] ?? ctx.user.featureFlags[rule])
}

The adapter keeps route metadata generic while allowing a project evaluator to combine its own permission stores, feature flags, or account state. Keep company, staff, buyer, and seller fields only when the application needs them. Do not claim that beforeResolve loaded the context unless an earlier guard or resolver did so.

  • String rules match a permission key or feature flag. Function rules run a custom predicate with the typed context. Use a string when a key check is enough; use a function for compound conditions.
  • The redirect field accepts a route-location object or a function (to) => routeLocation for dynamic redirects:
ts
redirect: (to) => ({ name: ROUTE_NAMES.ERROR_FORBIDDEN, query: { from: to.fullPath } })

A single global guard evaluates every permission on the matched routes:

ts
// src/router/guards/permissions.ts
import type { NavigationGuard } from 'vue-router'
import { storeToRefs } from 'pinia'
import { ROUTE_NAMES } from '@/config/routes/names'
import { useUserStore } from '@/stores/user.store'
import { useCompanyStore } from '@/stores/company.store'
import { evaluatePermission } from '@/router/permissions'
import type { PermissionContext, RoutePermission } from '@/types/router'

const permissionsGuard: NavigationGuard = (to) => {
  const routePermissions = to.matched
    .filter((record) => record.meta.permissions)
    .flatMap((record) => record.meta.permissions as RoutePermission[])

  if (!routePermissions.length) return

  const userStore = useUserStore()
  const companyStore = useCompanyStore()
  const { user, userActiveCompany } = storeToRefs(userStore)
  const { activeCompany } = storeToRefs(companyStore)

  const ctx: PermissionContext = {
    user: user.value,
    userActiveCompany: userActiveCompany.value,
    activeCompany: activeCompany.value,
  }

  for (const permission of routePermissions) {
    const passed = permission.rules.every((rule) => evaluatePermission(rule, ctx))

    if (!passed) {
      if (permission.redirect) {
        return typeof permission.redirect === 'function'
          ? permission.redirect(to)
          : permission.redirect
      }
      return { name: ROUTE_NAMES.ERROR_FORBIDDEN }
    }
  }
}

export default permissionsGuard

Register the guard on beforeResolve:

ts
// src/router/index.ts
import permissionsGuard from './guards/permissions'

// ...
router.beforeResolve(permissionsGuard)
// ...

export default router

Declare permissions on the route. The guard does the rest:

ts
{
  path: '/settings/billing',
  name: ROUTE_NAMES.SETTINGS_BILLING,
  component: () => import('@/pages/Settings/Billing/Billing.vue'),
  meta: {
    permissions: [
      { rules: ['canManageBilling'] },
      {
        rules: [({ userActiveCompany }) => Boolean(userActiveCompany?.staffIsAdmin)],
        redirect: { name: ROUTE_NAMES.ERROR_FORBIDDEN },
      },
    ],
  },
}

Global Resolve Guards

  • Use resolve guards for async data preparation that must finish after navigation guards succeed but before the component renders; keep access-control logic in navigation guards.
  • Store resolve guards under src/router/resolvers/<intent>.ts, one resolver per file.
  • Name the file in camelCase without the Resolver suffix (product.ts, dataPrefetch.ts).
  • Default-export each resolver function.
  • Suffix the resolver function name with Resolver to reflect its purpose (e.g., productResolver).
  • Register resolve guards with router.beforeResolve when you need to wait for async operations after in-component guards but before navigation finalizes.

Example of a resolve guard

ts
// src/router/resolvers/product.ts
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useProductStore } from '@/stores/product.store'

const productResolver = async (
  to: RouteLocationNormalized,
  _from: RouteLocationNormalized,
  next: NavigationGuardNext,
) => {
  const store = useProductStore()
  const productId = String(to.params.id)
  await store.fetchProduct(productId)
  next()
}

export default productResolver

Adding resolver to the router instance.

ts
// src/router/index.ts
import productResolver from './resolvers/product'

// ...
router.beforeResolve(productResolver)
// ...

export default router

For a route-specific permission resolver, restore the requiredPermission contract when a single permission key is enough. The resolver must load the permission store before it checks access:

ts
// src/router/resolvers/permissions.ts
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { usePermissionsStore } from '@/stores/permissions.store'
import { ROUTE_NAMES } from '@/config/routes/names'

const permissionsResolver = async (
  to: RouteLocationNormalized,
  _from: RouteLocationNormalized,
  next: NavigationGuardNext,
) => {
  const store = usePermissionsStore()
  if (to.meta.requiredPermission) {
    await store.ensureLoaded()
    if (!store.hasPermission(to.meta.requiredPermission)) {
      return next({ name: ROUTE_NAMES.ERROR_FORBIDDEN })
    }
  }
  next()
}

export default permissionsResolver

Register it with beforeResolve:

ts
// src/router/index.ts
import permissionsResolver from './resolvers/permissions'

// ...
router.beforeResolve(permissionsResolver)
// ...

export default router

Per-Route Guards

  • Use inline beforeEnter guards inside the route definition to handle simple, route-specific checks (e.g., permission gating for a single page).
  • Inline guards should remain concise; keep the logic focused on reading meta/config and deciding whether to allow navigation.
  • When the logic grows beyond a handful of lines or is reused across routes, extract it to src/router/guards/<intent>.ts and follow the navigation guard conventions above.

Example of an inline per-route guard

ts
// src/router/routes/profile.ts
import type { RouteRecordRaw } from 'vue-router'
import { usePermissionsStore } from '@/stores/permissions.store'
import { ROUTE_NAMES } from '@/config/routes/names'

const profileRoutes: RouteRecordRaw[] = [
  {
    path: '/profile',
    name: ROUTE_NAMES.PROFILE_ROOT,
    component: () => import('@/pages/Profile/Profile.vue'),
    beforeEnter: async (to, _from, next) => {
      const store = usePermissionsStore()
      await store.ensureLoaded()
      if (!store.hasPermission('profile:read')) {
        return next({ name: ROUTE_NAMES.ERROR_FORBIDDEN })
      }
      next()
    },
  },
]

export default profileRoutes

Guard Arrays (Route Middleware)

  • beforeEnter accepts an array of guard functions that run in order. This is the route-middleware pattern.
  • Reusable guards exported from src/router/guards/ compose as arrays on routes, chaining auth, data-fetch, and permission checks without inline logic. If a guard in the chain cancels navigation, the remaining guards do not run.
  • Use arrays when two or more guards apply to a route. Use a single inline function only for one-off logic that no other route needs.
ts
// src/router/routes/profile.ts
import { authGuard, fetchProfileData } from '@/router/guards'
import type { RouteRecordRaw } from 'vue-router'
import { ROUTE_NAMES } from '@/config/routes/names'

const profileRoutes: RouteRecordRaw[] = [
  {
    path: '/profile',
    name: ROUTE_NAMES.PROFILE_ROOT,
    component: () => import('@/pages/Profile/Profile.vue'),
    beforeEnter: [authGuard, fetchProfileData],
  },
]

In-Component Guards

  • Avoid using beforeRouteEnter, beforeRouteUpdate, or beforeRouteLeave inside components unless the logic depends on component instance state and cannot live in a route, module, or global guard.
  • If a component-level guard is justified, document the reason with a code comment immediately above the guard so the intent stays clear.
  • Prefer delegating to composables or shared guards rather than embedding complex navigation logic directly in components.

Example of a justified component guard

ts
// src/pages/Wizard/Wizard.vue
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const hasUnsavedWork = ref(false)

// Guard is local because it needs direct access to reactive form state
onBeforeRouteLeave((to, from, next) => {
  if (hasUnsavedWork.value && !window.confirm('You have unsaved changes. Leave anyway?')) {
    return next(false)
  }
  next()
})
</script>

Accessing Router Inside Components

  • Always use the Composition API helpers to interact with Vue Router inside components.
    • const router = useRouter(); gives access to navigation helpers like router.push while preserving the correct instance.
    • const route = useRoute(); exposes reactive information about the active route (params, query, meta, etc.).
  • Navigations (router.push, <router-link>, redirects) should reference route-name constants exported from src/config/routes/* to avoid drift when URLs change.
vue
<router-link :to="{ name: ROUTE_NAMES.CHECKOUT_ROOT }">Checkout</router-link>
ts
router.push({ name: ROUTE_NAMES.ERROR_NOT_FOUND })
  • Example usage:
ts
// src/pages/Checkout/Checkout.vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

const selectedTab = computed(() => route.query.tab ?? 'summary')

function goToConfirmation() {
  router.push({ name: ROUTE_NAMES.CHECKOUT_CONFIRMATION, params: { orderId: route.params.orderId } })
}
</script>

Typing params and queries

  • Narrow route params and query objects to avoid typos and missing keys.
  • Reuse Vue Router types like RouteLocationNormalizedLoaded['params'] to infer shapes from route records.
  • When parameters are optional, coerce them with explicit fallbacks or zod-style validators.
ts
import type { RouteLocationNormalizedLoaded } from 'vue-router'

type CheckoutParams = RouteLocationNormalizedLoaded['params'] & { orderId: string }

const route = useRoute()
const params = route.params as CheckoutParams

router.push({
  name: ROUTE_NAMES.CHECKOUT_CONFIRMATION,
  params: { orderId: params.orderId },
})

Route params and query values remain untyped at runtime (string | string[]). Treat the intersection above as a compile-time contract, not runtime validation. Use String(...) or explicit array handling to coerce values into the shape the consumer needs.

ts
const route = useRoute()
const orderId = String(route.params.orderId)

router.push({
  name: ROUTE_NAMES.CHECKOUT_CONFIRMATION,
  params: { orderId },
})
  • Vue Router offers typed routes via createRouter generics, but runtime coercion is the pragmatic default for most projects.

Error & Fallback Routes

  • Keep dedicated error and fallback routes in src/router/routes/errors.ts so every module can reference them consistently.
  • Provide named routes for common scenarios such as forbidden access, not-found pages, and generic failures.
  • Use a catch-all path (/:pathMatch(.*)*) to handle unknown URLs and render a not-found experience.
ts
// src/router/routes/errors.ts
import type { RouteRecordRaw } from 'vue-router'
import { ROUTE_NAMES } from '@/config/routes/names'

const errorRoutes: RouteRecordRaw[] = [
  {
    path: '/forbidden',
    name: ROUTE_NAMES.ERROR_FORBIDDEN,
    component: () => import('@/pages/Errors/Forbidden.vue'),
    meta: { title: 'Forbidden' },
  },
  {
    path: '/error',
    name: ROUTE_NAMES.ERROR_GENERIC,
    component: () => import('@/pages/Errors/Error.vue'),
    meta: { title: 'Something went wrong' },
  },
  {
    path: '/not-found',
    name: ROUTE_NAMES.ERROR_NOT_FOUND,
    component: () => import('@/pages/Errors/NotFound.vue'),
    meta: { title: 'Page not found' },
  },
]

export default errorRoutes

Lazy Loading and Prefetching

  • Use route.meta.prefetch flag to trigger pre-fetch of data for a route as soon as the user hovers over a link (using a custom directive such as v-prefetch).
  • In guard or component, check this flag and call the corresponding composable/service.
ts
// src/directives/prefetch.ts
import type { Directive } from 'vue'
import router from '@/router'

type PrefetchElement = HTMLElement & { __prefetchHandler__?: () => void }

export const prefetch: Directive<PrefetchElement, string> = {
  beforeMount(el, binding) {
    const handler = () => {
      const routeName = binding.value
      const targetRoute = router.resolve({ name: routeName })
      if (targetRoute.meta.prefetch) {
        // Call domain-specific prefetch logic here
      }
    }

    el.__prefetchHandler__ = handler
    el.addEventListener('mouseenter', handler)
  },
  unmounted(el) {
    if (el.__prefetchHandler__) {
      el.removeEventListener('mouseenter', el.__prefetchHandler__)
      delete el.__prefetchHandler__
    }
  },
}
ts
// src/main.ts
import { prefetch } from '@/directives/prefetch'

app.directive('prefetch', prefetch)

Typing Route Meta

Declare every custom meta property in the Vue Router module augmentation so editors surface the approved keys.

ts
// src/types/route-meta.d.ts
import 'vue-router'
import type { RoutePermission } from './router'

declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    requiredPermission?: string
    permissions?: RoutePermission[]
    prefetch?: boolean | string
    layout?: 'default' | 'full'
    analyticsName?: string
    /** Marketplace-compatible server-rendered boundary marker. */
    hasServerRenderedContent?: boolean | (() => boolean)
    /** Project-neutral alternative when the reference router marker is not used. */
    serverRendered?: boolean
  }
}

Use one complete meta contract when a route needs several cross-cutting concerns. Keep the keys project-approved and type them in the module augmentation:

ts
{
  path: '/settings/billing',
  name: ROUTE_NAMES.SETTINGS_BILLING,
  component: () => import('@/pages/Settings/Billing/Billing.vue'),
  meta: {
    layout: 'default',
    title: 'Billing',
    requiresAuth: true,
    permissions: [{ rules: ['canManageBilling'] }],
    analyticsName: 'billing',
    // Reference-router name and type. The adapter may also provide a function.
    hasServerRenderedContent: false,
  },
}

Full Navigation Resolution Flow

  1. Navigation triggered.
  2. Call beforeRouteLeave guards in deactivated components.
  3. Call global beforeEach guards.
  4. Call beforeRouteUpdate guards in reused components.
  5. Call beforeEnter guards declared on route records.
  6. Resolve async route components.
  7. Call beforeRouteEnter guards in activated components.
  8. Call global beforeResolve guards.
  9. Navigation is confirmed.
  10. Call global afterEach hooks.
  11. DOM updates trigger.
  12. Call callbacks passed to next in beforeRouteEnter guards with instantiated component instances.

Hybrid Routing and Server-Rendered Boundaries

  • Keep the greenfield route topology as the recommended target. A mature hybrid application may import feature modules directly into src/router/index.ts when that is the established ownership boundary. Do not copy this exception into a new application without a server or legacy integration need.
  • Use an eager component for the application shell, bootstrap path, authentication callback, layout, or a RouterView bridge when the component connects Vue to an existing host. Lazy-load ordinary Vue page boundaries.
  • Mark routes that cross a server-rendered boundary with a typed meta flag. In the reference router, use hasServerRenderedContent, which accepts a boolean or a zero-argument function. A project may use the neutral name serverRendered instead, but its adapter must map that name to the reference router's hasServerRenderedContent check. Do not treat either flag as a substitute for the navigation guard that enforces the handoff.
  • A bridge route may render a RouterView for Vue-owned children while handing other routes to Django or another server owner. Keep the bridge component and ownership rule close to the router configuration.
  • When a navigation crosses from a Vue page to a non-Vue page, or back, perform a full reload when the host must provide different HTML. A query-string or hash-only change on the same server-owned path may not require a reload when the host treats that change as in-page state.
  • Keep server-rendered route behavior in a named guard or adapter. Do not make hybrid markers, eager bridge components, or full reloads mandatory for greenfield Vue-only applications.

Rationale

  • Splitting routes by module keeps ownership clear, reduces merge conflicts, and makes it easy to extend the main route map with spreads.
  • Aligning page component filenames with their features ensures imports are unambiguous and mirrors the routing configuration.
  • Accessing the router through useRouter()/useRoute() preserves the active instance, keeps components test-friendly, and avoids coupling to global singletons.
  • Co-locating one guard or resolver per intent file keeps navigation logic discoverable, encourages reuse, and simplifies testing and tree-shaking.
  • Favoring concise per-route guards and avoiding in-component guards unless necessary keeps navigation concerns outside view logic and prevents duplicate permission checks.
  • Declaring permissions on route.meta and evaluating them with one global guard removes per-route permission logic from individual route definitions.
  • Composing reusable guards as beforeEnter arrays lets shared access-control and data-fetch steps live in one place and apply uniformly across routes.