Vue.js 58 🟢 Navigation Guards (Global beforeEach, Per-Route beforeEnter, In-Component onBeforeRouteLeave)
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
| Guard | Level | Runs On |
|---|---|---|
beforeEach | Global | Every navigation |
beforeResolve | Global | Every navigation, after components resolve |
afterEach | Global | After navigation confirmed (cannot cancel) |
beforeEnter | Per-route | Entering that specific route |
onBeforeRouteLeave | In-component | Component being removed |
onBeforeRouteUpdate | In-component | Component reused with new params |
Return Values (Vue Router 4)
| Return | Effect |
|---|---|
undefined or true | Proceed |
false | Cancel navigation |
| Route location | Redirect |
Throw Error | Cancel, trigger onError |
Execution Order
| Step | Guard |
|---|---|
| 1 | onBeforeRouteLeave (leaving component) |
| 2 | Global beforeEach |
| 3 | onBeforeRouteUpdate (reused component) |
| 4 | beforeEnter (route config) |
| 5 | onBeforeRouteEnter (entering component) |
| 6 | Global beforeResolve |
| 7 | Navigation confirmed |
| 8 | Global afterEach |
When Guards Do NOT Fire
| Change | Fires Guard? |
|---|---|
| Path segment change | Yes |
Param change (/u/2 → /u/3) | Only onBeforeRouteUpdate |
| Query change | Only onBeforeRouteUpdate |
| Hash change | Only 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Navigation hangs | next() not called in Vue Router 3 syntax | Use return-based guards |
| Double redirect | next() called in both branches | Return, don’t call next() |
beforeEnter not firing on param change | Only fires on route record change | Use onBeforeRouteUpdate |
| Auth check bypassed | New route added without meta.requiresAuth | Default to requiring auth, explicitly mark public routes |
| Leave guard does not prompt | Guard registered after navigation started | Register 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
| Item | Value |
|---|---|
| Global guard | router.beforeEach |
| Per-route guard | beforeEnter on route record |
| Leave guard | onBeforeRouteLeave in component |
| Update guard | onBeforeRouteUpdate in component |
| Resolve guard | router.beforeResolve |
| Return to proceed | undefined or true |
| Return to cancel | false |
| Return to redirect | Route location object |
next() status | Deprecated, supported but discouraged |
| Param change | Does not fire beforeEnter |
Key takeaways:
- The guard level determines the scope. Global
beforeEachruns on every navigation and is where authentication belongs. Per-routebeforeEnterruns 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(). Returnfalseto cancel, a route location to redirect, orundefined/trueto proceed. Thenext()argument still works but is a documented source of bugs . beforeEnterdoes not fire on param or query changes. Navigating from/users/2to/users/3reuses the route record, sobeforeEnteris skipped. UseonBeforeRouteUpdatefor that case .onBeforeRouteLeaveis 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 .beforeResolveis 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.
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!