14.5 Vue Router 4 — Declarative/Programmatic Navigation, Guards, Dynamic Routes
Vue Router 4 is the official routing library for Vue 3. It maps URLs to components in a Single Page Application and centralizes page-transition logic. It integrates fully with the Composition API, making navigation easy through the useRouter() and useRoute() hooks.
1. Installation and Basic Setup
npm install vue-router@4
Creating a Router Instance
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// Lazy loading reduces the initial bundle size
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/about',
name: 'About',
component: () => import('@/views/AboutView.vue'),
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('@/views/UserDetailView.vue'),
},
{
// 404 catch-all
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
// Control scroll behavior
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition // Restore position on back navigation
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
return { top: 0 } // Always scroll to top
},
})
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
App.vue — RouterView and RouterLink
<!-- App.vue -->
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink :to="{ name: 'UserDetail', params: { id: 1 } }">
User 1
</RouterLink>
</nav>
<!-- The component matched by the current route renders here -->
<RouterView />
</template>
2. Declarative Navigation — RouterLink
<RouterLink> replaces the HTML <a> tag and automatically applies an active CSS class when the current path matches.
<template>
<!-- Basic usage -->
<RouterLink to="/dashboard">Dashboard</RouterLink>
<!-- Name-based navigation (resilient to path changes) -->
<RouterLink :to="{ name: 'UserDetail', params: { id: userId } }">
My Profile
</RouterLink>
<!-- With query parameters and hash -->
<RouterLink :to="{ path: '/search', query: { q: 'vue', page: 1 }, hash: '#results' }">
Search
</RouterLink>
<!-- Custom active classes -->
<RouterLink
to="/admin"
active-class="nav-active"
exact-active-class="nav-exact"
>
Admin
</RouterLink>
<!-- Replace current history entry instead of pushing -->
<RouterLink to="/login" replace>Login</RouterLink>
</template>
Custom RouterLink Styling
<!-- NavBar.vue -->
<script setup>
import { RouterLink, useLink } from 'vue-router'
// useLink: access RouterLink's internal logic directly
const { href, isActive, isExactActive, navigate } = useLink({
to: '/dashboard',
})
</script>
<template>
<nav class="sidebar">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': isActive }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>
3. Programmatic Navigation
Use useRouter() to obtain the router instance and navigate pages directly from code.
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Reading current route information
console.log(route.path) // '/users/42'
console.log(route.params.id) // '42'
console.log(route.query.tab) // 'profile'
console.log(route.name) // 'UserDetail'
console.log(route.meta) // { requiresAuth: true, ... }
// Basic navigation
function goToHome() {
router.push('/')
}
// Name-based navigation
function goToUser(id) {
router.push({ name: 'UserDetail', params: { id } })
}
// Navigate with query parameters
function search(query) {
router.push({ path: '/search', query: { q: query, page: 1 } })
}
// Replace history entry (cannot go back)
function replaceLogin() {
router.replace({ name: 'Login' })
}
// Move forward/backward in history
function goBack() {
router.back() // same as router.go(-1)
}
function goForward() {
router.forward() // same as router.go(1)
}
</script>
Using push() as a Promise
// Perform work after navigation completes
async function submitForm(data) {
try {
await api.createUser(data)
await router.push({ name: 'UserDetail', params: { id: data.id } })
console.log('Navigation complete')
} catch (err) {
console.error('Navigation failed:', err)
}
}
4. Dynamic Routes
Parameterized Routes
// Route definitions
{
path: '/users/:id',
name: 'UserDetail',
component: UserDetail,
},
{
// Optional parameter
path: '/posts/:year(\\d{4})/:month(\\d{2})?',
name: 'PostArchive',
component: PostArchive,
},
{
// Repeated parameter (capture all segments)
path: '/files/:path+',
name: 'FileViewer',
component: FileViewer,
},
<!-- UserDetail.vue -->
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// Detect parameter changes within the same component
watch(
() => route.params.id,
async (newId) => {
if (newId) {
await fetchUser(newId)
}
},
{ immediate: true }
)
</script>
Passing Parameters as Props (Decoupling Component from Router)
// Set props: true in the route definition
{
path: '/users/:id',
component: UserDetail,
props: true, // params passed as component props
}
// Function-form props: include query parameters too
{
path: '/search',
component: SearchView,
props: (route) => ({ query: route.query.q, page: Number(route.query.page) || 1 }),
}
<!-- UserDetail.vue — decoupled from the router -->
<script setup>
defineProps({
id: String, // route.params.id is injected as a prop
})
</script>
Nested Routes
{
path: '/dashboard',
component: DashboardLayout,
children: [
{
path: '', // /dashboard
name: 'DashboardHome',
component: DashboardHome,
},
{
path: 'analytics', // /dashboard/analytics
name: 'Analytics',
component: AnalyticsView,
},
{
path: 'users', // /dashboard/users
name: 'UserList',
component: UserList,
children: [
{
path: ':id', // /dashboard/users/:id
name: 'UserProfile',
component: UserProfile,
},
],
},
],
}
<!-- DashboardLayout.vue -->
<template>
<div class="dashboard">
<Sidebar />
<main>
<!-- Where the nested route renders -->
<RouterView />
</main>
</div>
</template>
5. Navigation Guards
Navigation guards intercept route transitions to handle authentication, permission checks, and data prefetching.
Global Guard — beforeEach
// src/router/index.js
import { useAuthStore } from '@/stores/auth'
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// Check whether the route requires authentication
if (to.meta.requiresAuth && !auth.isLoggedIn) {
// Save the redirect target so we can return after login
return { name: 'Login', query: { redirect: to.fullPath } }
}
// Permission check
if (to.meta.roles && !to.meta.roles.includes(auth.userRole)) {
return { name: 'Forbidden' }
}
// Returning nothing (or true) allows navigation to proceed
})
// Global afterEach — update page title, fire analytics events
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} | MyApp` : 'MyApp'
})
Per-Route Guard — beforeEnter
{
path: '/admin',
component: AdminView,
meta: { requiresAuth: true, roles: ['admin'] },
beforeEnter: [checkPermission, loadAdminData],
}
function checkPermission(to, from) {
if (!isAdmin()) return { name: 'Home' }
}
async function loadAdminData(to, from) {
to.meta.adminData = await fetchAdminData()
}
In-Component Guards
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
const isDirty = ref(false)
// Before leaving the route — warn about unsaved changes
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const confirmed = window.confirm('You have unsaved changes. Do you want to leave?')
if (!confirmed) return false // Cancel navigation
}
})
// Detect route parameter changes within the same component
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await fetchUser(to.params.id)
}
})
</script>
Typing Route Meta Fields (TypeScript)
// src/types/router.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
layout?: 'default' | 'admin' | 'blank'
}
}
6. Real-World Example — Complete Authentication Flow
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/HomeView.vue') },
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: 'admin',
name: 'Admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
},
],
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{ path: 'login', name: 'Login', component: () => import('@/views/LoginView.vue') },
{ path: 'register', name: 'Register', component: () => import('@/views/RegisterView.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFoundView.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Authentication guard
router.beforeEach(async (to) => {
const auth = useAuthStore()
// If there's a token but no user, load user info (on page refresh)
if (auth.token && !auth.user) {
try {
await auth.fetchUser()
} catch {
auth.logout()
return { name: 'Login' }
}
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (to.meta.roles && !to.meta.roles.some((r) => auth.user?.roles.includes(r))) {
return { name: 'Home' }
}
// Redirect already-logged-in users away from the login page
if (to.name === 'Login' && auth.isLoggedIn) {
return { name: 'Dashboard' }
}
})
export default router
<!-- LoginView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const isLoading = ref(false)
async function handleLogin() {
error.value = ''
isLoading.value = true
try {
await auth.login({ email: email.value, password: password.value })
// Redirect to the page the user was trying to visit before login
const redirect = route.query.redirect as string
await router.push(redirect || { name: 'Dashboard' })
} catch (err) {
error.value = err.message || 'Login failed.'
} finally {
isLoading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleLogin" class="login-form">
<h1>Login</h1>
<div v-if="error" class="error-message">{{ error }}</div>
<label>
Email
<input v-model="email" type="email" required autocomplete="email" />
</label>
<label>
Password
<input v-model="password" type="password" required autocomplete="current-password" />
</label>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Logging in...' : 'Login' }}
</button>
<RouterLink :to="{ name: 'Register' }">Don't have an account? Sign up</RouterLink>
</form>
</template>
7. Adding Routes Dynamically — Role-Based Menus
A pattern for adding routes at runtime, common in admin systems.
// src/router/dynamicRoutes.js
const adminRoutes = [
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
},
{
path: '/admin/settings',
name: 'AdminSettings',
component: () => import('@/views/admin/SettingsView.vue'),
},
]
export function addDynamicRoutes(router, userRoles) {
if (userRoles.includes('admin')) {
adminRoutes.forEach((route) => {
if (!router.hasRoute(route.name)) {
router.addRoute(route)
}
})
}
}
// Usage — call after login
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (auth.isLoggedIn && !auth.routesAdded) {
addDynamicRoutes(router, auth.user.roles)
auth.routesAdded = true
// Navigate to the current route again to apply new routes
return to.fullPath
}
})
8. Route Transition Animations
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="route.meta.transition || 'fade'" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s ease;
}
.slide-right-enter-from {
transform: translateX(100%);
}
.slide-right-leave-to {
transform: translateX(-100%);
}
</style>
// Per-route transition configuration
{
path: '/detail/:id',
component: DetailView,
meta: { transition: 'slide-right' },
}
9. Expert Tips
useRoute() is Reactive — Be Careful with Destructuring
<script setup>
import { useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
// Safe: reference the route object itself
const userId = computed(() => route.params.id)
// Dangerous: only reads the initial value, misses later changes
// const { params } = route ← this breaks reactivity
</script>
Stronger Type Safety with unplugin-vue-router
npm install -D unplugin-vue-router
File-based routing with auto-generated types — route name typos become compile-time errors.
// vite.config.js
import VueRouter from 'unplugin-vue-router/vite'
export default {
plugins: [VueRouter()],
}
Data Prefetch Pattern
<script setup>
import { ref } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const user = ref(null)
const isLoading = ref(false)
async function loadUser(id) {
isLoading.value = true
try {
user.value = await fetchUser(id)
} finally {
isLoading.value = false
}
}
// Initial component load
await loadUser(route.params.id)
// Handle parameter changes within the same component
onBeforeRouteUpdate(async (to) => {
await loadUser(to.params.id)
})
</script>
Choosing a History Mode
| Mode | API | URL Example | Characteristics |
|---|---|---|---|
| HTML5 History | createWebHistory() | /users/1 | SEO-friendly, requires server config |
| Hash | createWebHashHistory() | /#/users/1 | No server config needed, poor SEO |
| Memory | createMemoryHistory() | — | For SSR and testing |