Vue router: routing and navigation guards
Vue Router maps URL paths to Vue components and controls navigation flow with guards - code that runs before, during, or after a route change.
Theory
TL;DR
- Vue Router = hotel front desk: URLs are room numbers, components are rooms, guards check ID at the door before letting anyone in
- Guard execution order:
beforeEach(global) →beforeEnter(per-route) → async components load →beforeResolve(global) → navigation commits →afterEach - Use
beforeEachfor auth redirects,beforeResolvefor data prefetching,afterEachfor analytics - Always put
/:pathMatch(.*)*last in your route array, or it swallows every path before specific routes get a chance to match
Quick example
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./Home.vue') },
{
path: '/dashboard',
component: () => import('./Dashboard.vue'),
meta: { requiresAuth: true } // read in the guard below
},
{ path: '/:pathMatch(.*)*', component: () => import('./NotFound.vue') } // wildcard last
]
})
router.beforeEach((to) => {
// Runs before every navigation
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default routerVisiting /dashboard without a token redirects to /login?redirect=/dashboard. With a token in localStorage, the Dashboard component loads normally.
Guards vs plain routing
Without guards, Vue Router is passive: URL changes, component swaps in, done. Guards turn that into active flow control. They pause the navigation, run your code, and only proceed if you allow it.
The difference matters when your app has auth, permissions, or data that must load before a page renders. Basic routing has no way to stop that. Guards do.
Three types of guards
Global guards attach to the router instance and run for every navigation in the app:
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return '/login'
}
})
router.afterEach((to) => {
analytics.track(to.fullPath) // fires after every successful navigation
})Per-route guards go directly in the route config. Useful when the logic applies to one route and does not need to pollute the global guard:
const routes = [
{
path: '/admin',
component: () => import('@/views/Admin.vue'),
beforeEnter: async (to, from) => {
const user = await fetchUser()
if (!user.isAdmin) return '/unauthorized'
}
}
]In-component guards use composables inside a component. The main use case is blocking navigation away from an unsaved form:
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return window.confirm('Leave without saving?')
}
})
</script>Guard execution order
This is the sequence Vue Router follows on every navigation:
router.beforeEach(global)beforeEnteron the target route- Async component resolution (dynamic imports run here)
router.beforeResolve(global, after components load)- Navigation commits, URL changes
router.afterEach(global, post-navigation)
beforeEach runs before any component loads, so it is cheap for redirects. beforeResolve is better for data fetches because by that point the component is confirmed to load. I have seen teams put heavy API calls in beforeEach and wonder why components flash before redirecting - that is why.
Route meta fields
Meta fields let you attach data to any route and read it inside guards:
const routes = [
{
path: '/profile/:id',
component: UserProfile,
meta: { requiresAuth: true, role: 'user' }
}
]
router.beforeEach((to) => {
if (to.meta.requiresAuth && !store.auth.isLoggedIn) {
return '/login?redirect=' + to.fullPath
}
})to.meta is available inside any guard, including global ones. Meta fields on parent routes are also accessible on child routes via to.matched.
Common mistakes
Calling next() twice. The callback-style API with next is still supported but trips developers regularly. Not returning after calling next means both branches execute:
// Wrong: both run when condition is false
router.beforeEach((to, from, next) => {
if (!auth) next('/login') // falls through!
next()
})
// Fix: early return
router.beforeEach((to, from, next) => {
if (!auth) return next('/login')
next()
})
// Better: use Vue Router 4 return style, drop next entirely
router.beforeEach((to) => {
if (!auth) return '/login'
})Wildcard route in the wrong position. /:pathMatch(.*)* matches any path. Put it first and nothing else in your route array gets evaluated:
// Wrong: /users/:id never matches
routes: [
{ path: '/:pathMatch(.*)*', component: NotFound }, // catches everything
{ path: '/users/:id', component: UserProfile }
]
// Fix: wildcard last
routes: [
{ path: '/users/:id', component: UserProfile },
{ path: '/:pathMatch(.*)*', component: NotFound }
]afterEach logs stale page views. Calling next(false) cancels navigation but afterEach still fires. Your analytics logs a page view for a page the user never reached. Check to.fullPath !== from.fullPath inside afterEach, or set a flag when cancelling.
Data fetching in beforeEach instead of beforeResolve. If you fetch data in beforeEach, the async component has not loaded yet. The fetch starts before the route is confirmed. Use beforeResolve for data that belongs to the resolved component.
Real-world usage
- Nuxt 3 wraps guards as
defineNuxtRouteMiddleware, handling SSR vs client differences (server reads cookies viauseRequestHeaders(), client useslocalStorage) - Quasar Framework registers global guards in
bootfiles for PWA offline checks before navigation - Pinia auth stores centralize guard logic:
useAuthStore().check()insidebeforeEachkeeps token validation in one place - VitePress uses
beforeEachhooks during navigation to trigger search index updates
Follow-up questions
Q: What is the full guard execution order?
A: router.beforeEach → route beforeEnter → async component resolution → router.beforeResolve → navigation commit → router.afterEach.
Q: When do you use beforeResolve instead of beforeEach?
A: beforeEach runs before any component loads, so use it for redirects and auth checks. beforeResolve runs after components are resolved but before render, making it the right place for data fetches tied to the component.
Q: How does Vue Router handle async guards?
A: Return a Promise from the guard. The router awaits it. If you use the next callback, call next() only after your async work completes.
Q: How do you access route meta in a guard?
A: Via to.meta.yourField. Add any property to the meta object in the route config and it is available inside all guards, including global ones.
Q: (Senior) In Nuxt SSR, how do you prevent guard logic from breaking on the server?
A: Server-side code cannot access localStorage. Use process.client to guard client-only code, and use useRequestHeaders() to read auth cookies on the server. Nuxt middleware with defineNuxtRouteMiddleware handles this separation cleanly.
Q: (Senior) A user clicks logout while a beforeEach guard is mid-flight on an async profile fetch. What breaks and how do you fix it?
A: The guard's await resolves after logout, so auth state is stale by the time the check runs and the navigation may still proceed. Fix: use AbortController on the fetch and re-check authStore.isLoggedIn after the await. If false, return /login immediately.
Examples
Auth guard with redirect preservation
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/Home.vue') },
{ path: '/login', component: () => import('@/views/Login.vue') },
{
path: '/profile/:id',
component: () => import('@/views/UserProfile.vue'),
meta: { requiresAuth: true }
}
]
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
// Preserve the intended path for post-login redirect
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default routerVisiting /profile/42 without auth redirects to /login?redirect=/profile/42. The login page reads route.query.redirect and sends the user straight to /profile/42 after a successful login.
Per-route async role check
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
beforeEnter: async (to, from) => {
const user = await fetchUser() // only runs when /dashboard is accessed
if (!user.isAdmin) return '/login'
}
}
]The API call only happens when someone visits /dashboard. Non-admins land on /login. The guard lives on the route config, so the global beforeEach stays clean and focused on auth only.
Nested routes with shared layout
const routes = [
{
path: '/dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true }, // applies to all children automatically
children: [
{ path: '', component: () => import('@/views/DashboardHome.vue') }, // /dashboard
{ path: 'settings', component: () => import('@/views/Settings.vue') }, // /dashboard/settings
{ path: 'profile', component: () => import('@/views/Profile.vue') } // /dashboard/profile
]
}
]<!-- DashboardLayout.vue -->
<template>
<div class="dashboard-layout">
<Sidebar />
<main>
<RouterView /> <!-- child route components render here -->
</main>
</div>
</template>meta: { requiresAuth: true } on the parent propagates to all children. One guard check in beforeEach covers the entire dashboard section without repeating the meta field on every child route.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.