Skip to main content
Advertisement

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 -->
<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>

<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>
<!-- 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

ModeAPIURL ExampleCharacteristics
HTML5 HistorycreateWebHistory()/users/1SEO-friendly, requires server config
HashcreateWebHashHistory()/#/users/1No server config needed, poor SEO
MemorycreateMemoryHistory()For SSR and testing
Advertisement