| |

Vue.js 58 🟢 Navigation Guards (Global beforeEach, Per-Route beforeEnter, In-Component onBeforeRouteLeave)

⏱️ Estimated read: 14 min | |

Navigation guards are the control points where a Vue application decides whether a route transition should proceed, be cancelled, or be redirected. They exist because navigation is not always safe: a user might be navigating to a page they are not authenticated for, or leaving a form with unsaved changes, or entering a route that requires data to be fetched first. The guard is the mechanism that intercepts the transition before it completes and makes that decision.

Vue Router organizes guards into three levels, and the level determines both when the guard runs and what it can access. Global guards run on every navigation, regardless of which route is involved. Per-route guards run only when entering a specific route. In-component guards run within the lifecycle of the component being rendered or removed. The execution order is fixed: the leaving component’s guard runs first, then global beforeEach, then the reused component’s update guard, then the route’s beforeEnter, then the entering component’s guard, and finally the global beforeResolve before the navigation is confirmed . Understanding this order is what prevents the classic mistake of putting authentication logic in the wrong guard and wondering why it runs too early or too late.

Key point: Global beforeEach runs on every navigation and is where authentication and global redirects belong; per-route beforeEnter runs only when entering that specific route and does not fire on param or query changes within the same route; in-component onBeforeRouteLeave runs when the component is about to be removed and is where unsaved-changes confirmation belongs. In Vue Router 4, guards should return a value (false, a route location, or undefined) instead of calling next().


Why the guard levels exist

The scope problem. Authentication is a global concern. Every route except the login page needs to check whether the user is logged in. Putting that check in every route’s beforeEnter would be repetitive and error-prone—a new route added without the check would be unprotected. The global beforeEach runs on every navigation, so a single check protects every route, and the login page is the only route that returns early without the check .

The route-specific problem. Some checks only make sense for one route. An admin panel might need to verify that the user has the admin role, which is not a global requirement. A product detail page might need to verify that the product ID in the URL actually exists. These checks belong in beforeEnter on that specific route, because running them on every navigation would be wasteful and would require the global guard to know about every route’s specific requirements .

The component lifecycle problem. A form with unsaved changes needs to warn the user before leaving. That concern is not about the route—it is about the component’s internal state. The onBeforeRouteLeave guard is the only place where the component’s state (hasUnsavedChanges, the form data) is accessible, because it runs while the component is still alive. A global guard cannot access that state, and a per-route guard does not know which component is mounted .

The next() deprecation problem. Vue Router 3 required calling next() exactly once in every guard. Forgetting to call it, calling it twice, or calling it conditionally without a return produced navigation hangs and silent failures. Vue Router 4 introduced return-based guards: returning false cancels, returning a route location redirects, and returning undefined or true continues. The next() argument is still supported for backward compatibility, but the official documentation recommends the return-based syntax because it eliminates the entire class of bugs that next() created .


a. Global beforeEach: the application-wide gate

router.beforeEach registers a guard that runs before every navigation. It receives to (the target route) and from (the current route), and returns one of four things: undefined or true to proceed, false to cancel, a route location to redirect, or a thrown Error to cancel and trigger the error handler.

// router.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', name: 'home', component: Home },
    { path: '/login', name: 'login', component: Login },
    { path: '/dashboard', name: 'dashboard', component: Dashboard, meta: { requiresAuth: true } },
  ],
})

router.beforeEach(async (to, from) => {
  // Check if the target route requires authentication
  const requiresAuth = to.meta.requiresAuth

  // If the route is public, allow the navigation
  if (!requiresAuth) return true

  // Check authentication state (async is supported)
  const isAuthenticated = await checkAuth()

  // If authenticated, allow
  if (isAuthenticated) return true

  // If not authenticated, redirect to login with a return path
  return { name: 'login', query: { redirect: to.fullPath } }
})

The meta.requiresAuth field is the standard pattern for marking routes that need protection. The guard checks the field, and if it is not set, the route is public and the navigation proceeds without an auth check . The redirect includes query.redirect with the original path, so the login page can send the user back to where they were trying to go after a successful login.

The guard can be async. Returning a Promise that resolves to a route location redirects; resolving to undefined or true proceeds. This is how the guard awaits checkAuth() without blocking the event loop .

beforeResolve is the other global guard worth knowing. It runs after all in-component guards and async route components have resolved, which makes it the correct place to fetch data that the component needs before it renders. A data prefetch that runs in beforeEach fires before the component exists; running it in beforeResolve ensures the component is ready to receive it .


b. Per-route beforeEnter: the route-specific check

beforeEnter is defined on a route record. It runs only when entering that route, and it does not fire when the params, query, or hash change within the same route. Navigating from /users/2 to /users/3 does not trigger beforeEnter on the /users/:id route, because the route record itself has not changed—only the param has .

// router.js
const routes = [
  {
    path: '/admin',
    component: AdminPanel,
    // Runs only when entering /admin, not on param changes
    beforeEnter: (to, from) => {
      // Check if the user has the admin role
      const hasAdminRole = store.getters.isAdmin
      // Cancel the navigation if not authorized
      if (!hasAdminRole) return false
      // Proceed if authorized
      return true
    },
  },
  {
    path: '/products/:id',
    component: ProductDetail,
    // Fetch the product before the component renders
    beforeEnter: async (to, from) => {
      try {
        // Fetch data using the route param
        await store.dispatch('fetchProduct', to.params.id)
        return true
      } catch (error) {
        // Cancel if the product does not exist
        return false
      }
    },
  },
]

The beforeEnter guard can be an array of functions. Each function runs in order, and the navigation stops if any of them returns a falsy value or a redirect. This is useful for composing reusable guards, such as a removeQueryParams guard that cleans the URL before entering .

The beforeEnter on a parent route does not fire when moving between its children. If /user has a beforeEnter and its children are /user/list and /user/details, navigating from /user/list to /user/details does not trigger the parent’s guard, because the parent route record has not changed. The guard must be placed on the child routes if it should fire for those transitions .


c. In-component onBeforeRouteLeave: the lifecycle guard

onBeforeRouteLeave is a Composition API function that registers a guard inside a component. It runs when the component is about to be unmounted because the route is changing. The component’s state is still accessible, which is what makes it the right place for unsaved-changes confirmation .

<!-- EditForm.vue -->
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

// The form state that determines whether the user has unsaved changes
const hasUnsavedChanges = ref(false)

onBeforeRouteLeave((to, from) => {
  // If there are no unsaved changes, allow the navigation
  if (!hasUnsavedChanges.value) return true

  // Ask the user for confirmation
  const answer = window.confirm('You have unsaved changes. Leave anyway?')

  // If the user cancels, return false to stay on the page
  if (!answer) return false

  // If the user confirms, allow the navigation
  return true
})
</script>

The guard returns false to cancel the navigation. The browser URL is reset to the current route, and the component stays mounted. Returning true or undefined allows the navigation to proceed .

onBeforeRouteUpdate is the companion guard for when the component is reused and the param changes. It does not fire on leave; it fires when navigating from /users/2 to /users/3 while the same component remains mounted. It is the correct place to re-fetch data for the new param .

<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

onBeforeRouteUpdate(async (to, from) => {
  // Only fetch if the id actually changed
  if (to.params.id !== from.params.id) {
    // Re-fetch the user data for the new id
    userData.value = await fetchUser(to.params.id)
  }
})
</script>

The Composition API guards can be used in any component rendered by <router-view>, not just the route component itself. This means a nested component can register a leave guard for unsaved changes without the parent route component needing to know about it .


Complete Example Session

The following session builds a router with a global auth guard, a per-route admin check, and an in-component leave confirmation.

// ============================================
// PART 1: THE ROUTER WITH GUARDS
// ============================================
import { createRouter, createWebHistory } from 'vue-router'
import Home from './Home.vue'
import Login from './Login.vue'
import Dashboard from './Dashboard.vue'
import AdminPanel from './AdminPanel.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', name: 'home', component: Home },
    { path: '/login', name: 'login', component: Login },
    {
      path: '/dashboard',
      name: 'dashboard',
      component: Dashboard,
      // Mark this route as requiring authentication
      meta: { requiresAuth: true },
    },
    {
      path: '/admin',
      name: 'admin',
      component: AdminPanel,
      // Mark this route as requiring both auth and admin role
      meta: { requiresAuth: true, requiresAdmin: true },
      // Per-route guard: runs only when entering /admin
      beforeEnter: (to, from) => {
        // Check the admin role from the store
        const isAdmin = store.getters.isAdmin
        // Cancel navigation if not admin
        if (!isAdmin) return false
        return true
      },
    },
  ],
})

// ============================================
// PART 2: GLOBAL AUTH GUARD
// ============================================
router.beforeEach(async (to, from) => {
  // If the route does not require auth, allow it
  if (!to.meta.requiresAuth) return true

  // Check the authentication state
  const isAuthenticated = await checkAuth()

  // Redirect to login if not authenticated
  if (!isAuthenticated) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  // Allow the navigation if authenticated
  return true
})
<!-- ============================================
     PART 3: THE COMPONENT WITH LEAVE GUARD
     ============================================ -->
<!-- EditForm.vue -->
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

// Track whether the form has been modified
const hasUnsavedChanges = ref(false)

// Register the leave guard
onBeforeRouteLeave((to, from) => {
  // Skip the prompt if there are no changes
  if (!hasUnsavedChanges.value) return true

  // Ask the user before leaving
  const answer = window.confirm('Discard unsaved changes?')

  // Cancel navigation if the user clicks "Cancel"
  if (!answer) return false

  // Allow navigation if the user confirms
  return true
})
</script>

The route configuration defines the global guard that checks authentication for any route with meta.requiresAuth, and the per-route guard that checks the admin role only for /admin. The component registers a leave guard that prompts the user only when there are unsaved changes. Each guard runs at its own level and for its own purpose.


Quick Reference

Guard Levels

GuardLevelRuns On
beforeEachGlobalEvery navigation
beforeResolveGlobalEvery navigation, after components resolve
afterEachGlobalAfter navigation confirmed (cannot cancel)
beforeEnterPer-routeEntering that specific route
onBeforeRouteLeaveIn-componentComponent being removed
onBeforeRouteUpdateIn-componentComponent reused with new params

Return Values (Vue Router 4)

ReturnEffect
undefined or trueProceed
falseCancel navigation
Route locationRedirect
Throw ErrorCancel, trigger onError

Execution Order

StepGuard
1onBeforeRouteLeave (leaving component)
2Global beforeEach
3onBeforeRouteUpdate (reused component)
4beforeEnter (route config)
5onBeforeRouteEnter (entering component)
6Global beforeResolve
7Navigation confirmed
8Global afterEach

When Guards Do NOT Fire

ChangeFires Guard?
Path segment changeYes
Param change (/u/2 → /u/3)Only onBeforeRouteUpdate
Query changeOnly onBeforeRouteUpdate
Hash changeOnly onBeforeRouteUpdate

Best Practices

✅ Do This:

// Return-based syntax, no next()
router.beforeEach(async (to, from) => {
  if (!await isAuth()) return { name: 'login', query: { redirect: to.fullPath } }
})

// Per-route check only on that route
{ path: '/admin', beforeEnter: () => hasRole('admin') }

// Leave confirmation in the component that owns the state
onBeforeRouteLeave(() => hasUnsaved ? confirm('Leave?') : true)

❌ Don’t Do This:

// Calling next() twice or forgetting to call it
router.beforeEach((to, from, next) => {
  if (!isAuth) next('/login')
  next() // runs even after redirect
})

// Auth check in beforeEnter on every route
{ path: '/a', beforeEnter: checkAuth } // repetitive and easy to miss
{ path: '/b', beforeEnter: checkAuth }

// Expecting beforeEnter on param change
// /users/2 → /users/3 does NOT fire beforeEnter

Common Pitfalls

PitfallWhy It HappensFix
Navigation hangsnext() not called in Vue Router 3 syntaxUse return-based guards
Double redirectnext() called in both branchesReturn, don’t call next()
beforeEnter not firing on param changeOnly fires on route record changeUse onBeforeRouteUpdate
Auth check bypassedNew route added without meta.requiresAuthDefault to requiring auth, explicitly mark public routes
Leave guard does not promptGuard registered after navigation startedRegister in setup before any async operation

Real-World Examples

1. Global auth redirect

// Redirect unauthenticated users to login
router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isAuth()) return { name: 'login' }
})

2. Per-route admin check

// Only allow admins into the admin panel
{ path: '/admin', beforeEnter: () => store.getters.isAdmin }

3. Leave confirmation

// Prompt before discarding unsaved changes
onBeforeRouteLeave(() => !dirty.value || confirm('Discard?'))

4. Param-change data refresh

// Re-fetch when the reused component's param changes
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) await fetchUser(to.params.id)
})

5. Data prefetch with beforeResolve

// Fetch data after components resolve, before render
router.beforeResolve(async (to) => {
  if (to.meta.prefetch) await to.meta.prefetch(to)
})

6. Route metadata for guards

// Mark routes with metadata that guards read
{ path: '/dashboard', meta: { requiresAuth: true } }

7. Array of beforeEnter guards

// Run multiple guards in order
{ path: '/x', beforeEnter: [removeQuery, checkAuth] }

8. Login redirect with return path

// Preserve the intended destination
return { name: 'login', query: { redirect: to.fullPath } }

9. afterEach for analytics

// Track page views after navigation confirms
router.afterEach((to) => analytics.page(to.fullPath))

10. Cancel with error

// Cancel and trigger the global error handler
throw new Error('Unauthorized')

Visual

Guard Execution Order

┌──────────────────────────────────────────────┐
│  Navigation triggered                        │
│         │                                    │
│         ▼                                    │
│  onBeforeRouteLeave (leaving component)      │
│         │                                    │
│         ▼                                    │
│  Global beforeEach                           │
│         │                                    │
│         ▼                                    │
│  onBeforeRouteUpdate (reused component)      │
│         │                                    │
│         ▼                                    │
│  beforeEnter (route config)                  │
│         │                                    │
│         ▼                                    │
│  onBeforeRouteEnter (entering component)     │
│         │                                    │
│         ▼                                    │
│  Global beforeResolve                        │
│         │                                    │
│         ▼                                    │
│  Navigation confirmed                        │
│         │                                    │
│         ▼                                    │
│  Global afterEach                            │
└──────────────────────────────────────────────┘

Return-Based Guards

┌──────────────────────────────────────────────┐
│  router.beforeEach((to) => {                 │
│    if (!auth) return { name: 'login' }       │
│    // undefined → proceed                    │
│  })                                          │
│                                              │
│  Return values:                              │
│  undefined/true → next guard                 │
│  false          → cancel                     │
│  route location → redirect                   │
│  Error          → cancel + onError           │
└──────────────────────────────────────────────┘

When Each Guard Fires

┌──────────────────────────────────────────────┐
│  /users/2 → /users/3 (same route)            │
│  beforeEnter:      ❌ does not fire          │
│  onBeforeRouteUpdate: ✅ fires               │
│                                              │
│  /users/2 → /admin                           │
│  beforeEnter (/admin): ✅ fires              │
│  onBeforeRouteUpdate: ❌ different route     │
│                                              │
│  Leaving a component                         │
│  onBeforeRouteLeave: ✅ fires                │
└──────────────────────────────────────────────┘

The next() Problem

┌──────────────────────────────────────────────┐
│  BAD (Vue Router 3 style):                   │
│  beforeEach((to, from, next) => {            │
│    if (!auth) next('/login')                 │
│    next() // called twice when !auth         │
│  })                                          │
│                                              │
│  GOOD (Vue Router 4):                        │
│  beforeEach((to, from) => {                  │
│    if (!auth) return '/login'                │
│    // return undefined → proceed             │
│  })                                          │
└──────────────────────────────────────────────┘

Summary

ItemValue
Global guardrouter.beforeEach
Per-route guardbeforeEnter on route record
Leave guardonBeforeRouteLeave in component
Update guardonBeforeRouteUpdate in component
Resolve guardrouter.beforeResolve
Return to proceedundefined or true
Return to cancelfalse
Return to redirectRoute location object
next() statusDeprecated, supported but discouraged
Param changeDoes not fire beforeEnter

Key takeaways:

  • The guard level determines the scope. Global beforeEach runs on every navigation and is where authentication belongs. Per-route beforeEnter runs only when entering that route. In-component guards run within the component’s lifecycle and have access to its state .
  • Vue Router 4 uses return values, not next(). Return false to cancel, a route location to redirect, or undefined/true to proceed. The next() argument still works but is a documented source of bugs .
  • beforeEnter does not fire on param or query changes. Navigating from /users/2 to /users/3 reuses the route record, so beforeEnter is skipped. Use onBeforeRouteUpdate for that case .
  • onBeforeRouteLeave is for unsaved changes. It is the only guard that runs while the component’s state is still accessible, which is what makes it the right place for a confirmation prompt .
  • beforeResolve is for data prefetch. It runs after all components and async routes have resolved, so the data it fetches is available when the component renders .
  • The execution order is fixed. Leave guard → global beforeEach → update guard → beforeEnter → enter guard → beforeResolve → confirm → afterEach. Knowing the order prevents putting logic in a guard that runs at the wrong time .

Remember: Navigation guards are the application’s routing policy. The global guard is the gate that every navigation must pass. The per-route guard is the specific check for a specific destination. The in-component guard is the lifecycle hook that protects the component’s own state. They run in a defined order, and the return-based syntax in Vue Router 4 eliminates the next() bugs that plagued earlier versions. Use the global guard for authentication, the per-route guard for route-specific permissions and data validation, and the in-component guard for unsaved changes. Do not put authentication in beforeEnter on every route, and do not expect beforeEnter to fire when only the param changes.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!