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.
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'
}
}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.
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.
// 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.
<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><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.
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 }
})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<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
beforeEachguard 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
Q1. Why should a navigation guard return a redirect location object instead of calling router.push() directly?
Q2. What must wrap a component that uses a top-level await in its <script setup> block?