Routing is where authentication, authorization, and bundle-splitting decisions converge into a single configuration surface. Getting the typed contracts right here — especially RouteMeta — prevents an entire class of "forgot to check permissions" bugs that are otherwise invisible to the compiler.


Type-Safe Route Configuration

Vue Router ships its own RouteMeta interface as an empty, augmentable shape — exactly like Pinia's plugin option augmentation. Declaring your app's actual meta fields via module augmentation makes route.meta.requiresAuth a compile-time-checked property instead of an untyped grab-bag.

router/routeMeta.d.tstypescript
import 'vue-router'

export type UserRole = 'admin' | 'editor' | 'viewer'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    allowedRoles?: UserRole[]
    title?: string
    layout?: 'default' | 'auth' | 'dashboard'
  }
}
router/routes.tstypescript
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue'),
    meta: { title: 'Home', layout: 'default' }
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/LoginView.vue'),
    meta: { title: 'Sign In', layout: 'auth' }
  },
  {
    path: '/products/:id',
    name: 'product-detail',
    component: () => import('@/views/ProductDetailView.vue'),
    props: (route) => ({ id: String(route.params.id) }), // explicit typed mapping, safer than props: true
    meta: { title: 'Product Detail', layout: 'default' }
  },
  {
    path: '/dashboard',
    component: () => import('@/layouts/DashboardLayout.vue'),
    meta: { requiresAuth: true, layout: 'dashboard' },
    children: [
      {
        path: '',
        name: 'dashboard-overview',
        component: () => import('@/views/dashboard/OverviewView.vue'),
        meta: { requiresAuth: true, allowedRoles: ['admin', 'editor', 'viewer'], title: 'Overview' }
      },
      {
        path: 'billing',
        name: 'dashboard-billing',
        component: () => import('@/views/dashboard/BillingView.vue'),
        meta: { requiresAuth: true, allowedRoles: ['admin'], title: 'Billing' }
      }
    ]
  }
]

Navigation Guards & Async Hydration

Guards run in a strict pipeline: global beforeEach, then route-level beforeEnter, then in-component beforeRouteEnter, resolving in order before the navigation is confirmed. A guard that returns a Promise pauses navigation until it resolves — this is the mechanism for "async hydration": verifying a session token against the server before allowing a protected route to render.

router/guards.tstypescript
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'

export function registerGuards(router: Router): void {
  router.beforeEach(async (to, from) => {
    const auth = useAuthStore()

    if (!to.meta.requiresAuth) {
      return true
    }

    // Async hydration: if the store hasn't yet confirmed the session (e.g. hard
    // page reload, store is freshly initialized), validate the token against
    // the server before deciding whether to allow navigation.
    if (!auth.isHydrated) {
      try {
        await auth.hydrateFromToken()
      } catch {
        return { name: 'login', query: { redirect: to.fullPath } }
      }
    }

    if (!auth.isAuthenticated) {
      return { name: 'login', query: { redirect: to.fullPath } }
    }

    const allowedRoles = to.meta.allowedRoles
    if (allowedRoles && !allowedRoles.includes(auth.currentUser?.role ?? 'viewer')) {
      return { name: 'forbidden' }
    }

    return true
  })

  router.afterEach((to) => {
    if (to.meta.title) {
      document.title = `${to.meta.title} — Enterprise App`
    }
  })
}

Lazy Loading Strategies

component: () => import('...') is the boundary Vite/Rollup use to split a chunk. Grouping related routes into the same chunk (via the Rollup magic comment) reduces the number of separate network requests for features that are always navigated together, at the cost of a slightly larger shared chunk.

router/lazyChunks.tstypescript
// Named chunk grouping: both routes below compile into a single
// 'dashboard-billing' chunk file, fetched once regardless of which
// of the two sub-routes is visited first.
const BillingOverview = () => import(/* webpackChunkName: "dashboard-billing" */ '@/views/dashboard/BillingView.vue')
const BillingHistory = () => import(/* webpackChunkName: "dashboard-billing" */ '@/views/dashboard/BillingHistoryView.vue')

<Suspense> for Async Component Boundaries

<Suspense> renders a fallback while an async setup component (one with a top-level await in <script setup>) resolves, and swaps to the real content once resolved — the client-side equivalent of a loading boundary, without manually tracking a loading ref per route.

App.vue (router-view with Suspense + error boundary)vue
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'

const routeError = ref<Error | null>(null)

onErrorCaptured((err) => {
  routeError.value = err instanceof Error ? err : new Error(String(err))
  return false // stop propagation — this boundary owns the error
})
</script>

<template>
  <div v-if="routeError" class="route-error">
    <h2>Something went wrong loading this page.</h2>
    <button type="button" @click="routeError = null">Dismiss</button>
  </div>

  <router-view v-else v-slot="{ Component, route }">
    <Suspense timeout="0">
      <template #default>
        <component :is="Component" :key="route.path" />
      </template>
      <template #fallback>
        <div class="route-loading">Loading {{ route.meta.title ?? 'page' }}...</div>
      </template>
    </Suspense>
  </router-view>
</template>
views/dashboard/BillingView.vue (async setup component)vue
<script setup lang="ts">
import { ref } from 'vue'

interface Invoice { id: string; amount: number; issuedAt: string }

// A top-level await inside <script setup> makes this an async component —
// Vue requires it to be a descendant of <Suspense>, or it throws at runtime.
const response = await fetch('/api/billing/invoices')
const invoices = ref<Invoice[]>(await response.json())
</script>

<template>
  <ul>
    <li v-for="invoice in invoices" :key="invoice.id">
      {{ invoice.id }} — ${{ invoice.amount.toFixed(2) }}
    </li>
  </ul>
</template>

Practical Example: RBAC Routing System

Bringing typed meta, async guard hydration, lazy loading, and a dedicated forbidden route together into one enterprise-grade router setup.

stores/authStore.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserRole } from '@/router/routeMeta'

interface CurrentUser {
  id: string
  name: string
  role: UserRole
}

export const useAuthStore = defineStore('auth', () => {
  const currentUser = ref<CurrentUser | null>(null)
  const isHydrated = ref(false)

  const isAuthenticated = computed(() => currentUser.value !== null)

  async function hydrateFromToken(): Promise<void> {
    const token = localStorage.getItem('auth_token')
    if (!token) {
      isHydrated.value = true
      return
    }
    const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
    if (!res.ok) {
      isHydrated.value = true
      throw new Error('Session validation failed')
    }
    currentUser.value = await res.json()
    isHydrated.value = true
  }

  function logout(): void {
    currentUser.value = null
    localStorage.removeItem('auth_token')
  }

  return { currentUser, isHydrated, isAuthenticated, hydrateFromToken, logout }
})
router/index.tstypescript
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { registerGuards } from './guards'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    ...routes,
    {
      path: '/forbidden',
      name: 'forbidden',
      component: () => import('@/views/ForbiddenView.vue'),
      meta: { title: 'Access Denied' }
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'not-found',
      component: () => import('@/views/NotFoundView.vue'),
      meta: { title: 'Not Found' }
    }
  ]
})

registerGuards(router)

export default router
views/ForbiddenView.vuevue
<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()
</script>

<template>
  <div class="forbidden">
    <h1>403 — Access Denied</h1>
    <p>Your account role does not have permission to view this page.</p>
    <button type="button" @click="router.push({ name: 'home' })">Return Home</button>
  </div>
</template>

<style scoped>
.forbidden { text-align: center; padding: 3rem 1rem; font-family: system-ui, sans-serif; }
</style>

Why This RBAC Design Holds Up at Scale

  • Role checks live entirely in route meta, never scattered across individual component logic — auditing 'who can see what' is a single grep through routes.ts
  • The beforeEach guard is the single enforcement point; a component that forgets a role check cannot accidentally expose a protected view, since the router never renders it in the first place
  • hydrateFromToken() runs at most once per hard page load (gated by isHydrated), so client-side navigations between already-authenticated routes incur zero extra network round-trips
  • Redirecting to { name: 'login', query: { redirect: to.fullPath } } lets the login view send the user back to their originally intended destination after successful authentication

Module 5 Checkpoint

  1. Q1. Why should a navigation guard return a redirect location object instead of calling router.push() directly?

  2. Q2. What must wrap a component that uses a top-level await in its <script setup> block?