Skip to main content

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 beforeEach for auth redirects, beforeResolve for data prefetching, afterEach for analytics
  • Always put /:pathMatch(.*)* last in your route array, or it swallows every path before specific routes get a chance to match

Quick example

typescript
// 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 router

Visiting /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:

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

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

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

  1. router.beforeEach (global)
  2. beforeEnter on the target route
  3. Async component resolution (dynamic imports run here)
  4. router.beforeResolve (global, after components load)
  5. Navigation commits, URL changes
  6. 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:

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

typescript
// 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:

typescript
// 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 via useRequestHeaders(), client uses localStorage)
  • Quasar Framework registers global guards in boot files for PWA offline checks before navigation
  • Pinia auth stores centralize guard logic: useAuthStore().check() inside beforeEach keeps token validation in one place
  • VitePress uses beforeEach hooks 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

typescript
// 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 router

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

typescript
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

typescript
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 ] } ]
vue
<!-- 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?