Fix: Vue Router Navigation Guard Not Working — beforeEach and Route Guards
Quick Answer
How to fix Vue Router navigation guards not working — beforeEach, beforeEnter, in-component guards, async guards, redirect loops, and route meta authentication patterns.
The Problem
A Vue Router navigation guard doesn’t prevent unauthorized access:
// Navigation guard set up, but protected routes are still accessible
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login');
}
// Forgot to call next() for the else case — navigation hangs
});Or the guard fires but the redirect doesn’t work — the page stays blank or the URL doesn’t change:
router.beforeEach((to, from, next) => {
next({ name: 'Login' });
// Works, but the page is blank after redirect
});Or an async guard doesn’t wait for the authentication check to complete:
router.beforeEach((to, from, next) => {
fetch('/api/me').then(res => {
if (!res.ok) next('/login');
});
// next() called before the fetch completes — guard doesn't block
});Or an infinite redirect loop:
NavigationDuplicated: Avoided redundant navigation to current location: "/login"Why This Happens
Vue Router navigation guards have specific calling conventions that are easy to get wrong:
- Forgetting to call
next()— every guard must callnext()to continue navigation. If theelsebranch is missing anext()call, navigation hangs indefinitely. - Calling
next()multiple times — callingnext()more than once in a guard causes unpredictable behavior. Only onenext()call per guard invocation. - Async guards without
async/await— using callbacks (.then()) inside a guard and callingnext()outside the callback means the navigation proceeds before the async operation resolves. - Redirect loop — a guard redirects to
/login, but the/loginroute also triggers the guard, which redirects again. The loop continues until the browser throws an error. - Using Vue Router 4 without
next(composition API style) — Vue Router 4 supports returning a route location from the guard instead of callingnext(). Mixing the two styles causes issues. - Route meta not defined — checking
to.meta.requiresAuthwhen the route’smetaobject doesn’t have that field returnsundefined(falsy), so the guard passes when it shouldn’t.
Fix 1: Always Call next() in Every Code Path
Every navigation guard must call next() exactly once for every possible execution path:
// WRONG — missing next() in the else branch
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login');
// But what if the user IS authenticated? next() is never called → navigation hangs
}
});// CORRECT — every code path calls next()
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login'); // Redirect to login
} else {
next(); // Continue navigation
}
});Multi-condition guard with all paths covered:
router.beforeEach((to, from, next) => {
const isAuth = isAuthenticated();
const requiresAuth = to.meta.requiresAuth;
const requiresAdmin = to.meta.requiresAdmin;
if (requiresAuth && !isAuth) {
// Not authenticated → login
next({ name: 'Login', query: { redirect: to.fullPath } });
} else if (requiresAdmin && !isAdmin()) {
// Not admin → forbidden
next({ name: 'Forbidden' });
} else {
// All checks passed → continue
next();
}
});Fix 2: Use async/await for Async Guards
When the guard needs to wait for an async operation (API call, store action), use async/await:
// WRONG — fetch result not awaited, next() called immediately
router.beforeEach((to, from, next) => {
fetch('/api/me')
.then(res => {
if (!res.ok) next('/login');
// next() not called if res.ok is true
});
// Navigation continues immediately without waiting for fetch
});// CORRECT — async guard with await
router.beforeEach(async (to, from, next) => {
if (!to.meta.requiresAuth) {
return next(); // No auth needed — continue immediately
}
try {
const res = await fetch('/api/me');
if (res.ok) {
next(); // Authenticated — continue
} else {
next('/login'); // Not authenticated — redirect
}
} catch {
next('/login'); // Network error — redirect to login
}
});Vue Router 4 — return a route instead of calling next():
In Vue Router 4, you can return a route location from the guard (no next parameter needed):
// Vue Router 4 style — return value controls navigation
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) {
return true; // or return undefined — continue navigation
}
const user = await fetchCurrentUser();
if (!user) {
// Returning a route location redirects to that route
return {
name: 'Login',
query: { redirect: to.fullPath },
};
}
return true; // Continue navigation
});Note: Don’t mix the
nextcallback style and the return value style in Vue Router 4. Pick one approach and use it consistently. The return value style (nonextparameter) is cleaner and eliminates the “missing next()” mistake entirely.
Fix 3: Fix Redirect Loops
A redirect loop happens when the guard redirects to a route that also triggers the guard:
// WRONG — guard redirects /login to /login → infinite loop
router.beforeEach((to, from, next) => {
if (!isAuthenticated()) {
next('/login'); // Redirects to /login
// /login also triggers this guard
// Guard fires again, redirects to /login again
// → NavigationDuplicated error
} else {
next();
}
});Fix — exclude public routes from the auth check:
// CORRECT — whitelist public routes
const publicRoutes = ['/login', '/register', '/forgot-password', '/about'];
router.beforeEach((to, from, next) => {
const isPublic = publicRoutes.includes(to.path);
if (!isPublic && !isAuthenticated()) {
next({ path: '/login', query: { redirect: to.fullPath } });
} else {
next();
}
});Better approach — use route meta to mark routes:
const routes = [
{ path: '/login', component: LoginView }, // No meta
{ path: '/register', component: RegisterView }, // No meta
{ path: '/dashboard', component: DashboardView, meta: { requiresAuth: true } },
{ path: '/admin', component: AdminView, meta: { requiresAuth: true, requiresAdmin: true } },
];
router.beforeEach((to, from, next) => {
// Only check routes explicitly marked as requiring auth
if (to.meta.requiresAuth && !isAuthenticated()) {
next({ path: '/login', query: { redirect: to.fullPath } });
} else {
next();
}
// Routes without meta.requiresAuth (login, register) pass through without redirect
});Also guard against already-authenticated users visiting login:
router.beforeEach((to, from, next) => {
const isAuth = isAuthenticated();
if (to.meta.requiresAuth && !isAuth) {
// Not authenticated, trying to access protected route → login
next({ path: '/login', query: { redirect: to.fullPath } });
} else if (to.name === 'Login' && isAuth) {
// Already authenticated, trying to visit login → dashboard
next({ name: 'Dashboard' });
} else {
next();
}
});Fix 4: Use Per-Route Guards with beforeEnter
For route-specific logic, use beforeEnter on individual routes instead of a global beforeEach:
const routes = [
{
path: '/admin',
component: AdminView,
beforeEnter: (to, from, next) => {
if (!isAdmin()) {
next({ name: 'Forbidden' });
} else {
next();
}
},
},
{
path: '/profile/:id',
component: ProfileView,
// Array of guards — all must pass
beforeEnter: [checkAuthenticated, checkProfileOwner],
},
];
function checkAuthenticated(to, from, next) {
if (!isAuthenticated()) {
next('/login');
} else {
next();
}
}
function checkProfileOwner(to, from, next) {
const userId = getCurrentUserId();
if (to.params.id !== String(userId) && !isAdmin()) {
next({ name: 'Forbidden' });
} else {
next();
}
}Fix 5: Use In-Component Guards
For component-level navigation control, use beforeRouteEnter, beforeRouteUpdate, and beforeRouteLeave:
<script>
export default {
// Called before the route that renders this component is confirmed
// Note: component instance is NOT yet created — can't use `this`
beforeRouteEnter(to, from, next) {
next(vm => {
// `vm` is the component instance — use this to initialize
vm.loadData();
});
},
// Called when the route changes but the component is reused
// (e.g., /users/1 → /users/2)
beforeRouteUpdate(to, from, next) {
this.loadData(to.params.id);
next();
},
// Called when navigating away — useful for unsaved changes warning
beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const confirmed = window.confirm('Leave without saving?');
if (confirmed) {
next();
} else {
next(false); // Cancel navigation
}
} else {
next();
}
},
};
</script>Composition API with onBeforeRouteLeave:
<script setup>
import { ref } from 'vue';
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
const hasUnsavedChanges = ref(false);
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const confirmed = window.confirm('Leave without saving?');
if (!confirmed) return false; // Returning false cancels navigation
}
});
onBeforeRouteUpdate(async (to) => {
await loadData(to.params.id);
});
</script>Fix 6: Integrate with Pinia for Auth State
Use Pinia (the standard Vue 3 state manager) to share auth state between the router and components:
// stores/auth.ts
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: localStorage.getItem('token'),
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.user,
isAdmin: (state) => state.user?.role === 'admin',
},
actions: {
async fetchCurrentUser() {
if (!this.token) return;
try {
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${this.token}` },
});
if (res.ok) {
this.user = await res.json();
} else {
this.logout();
}
} catch {
this.logout();
}
},
logout() {
this.user = null;
this.token = null;
localStorage.removeItem('token');
},
},
});// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to) => {
const authStore = useAuthStore();
// Fetch current user on first navigation if token exists
if (authStore.token && !authStore.user) {
await authStore.fetchCurrentUser();
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } };
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return { name: 'Forbidden' };
}
});
export default router;Redirect after login — use the redirect query parameter:
<!-- views/LoginView.vue -->
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
async function login(credentials) {
await authStore.login(credentials);
// Redirect to the originally requested page, or dashboard
const redirectTo = route.query.redirect as string || '/dashboard';
router.push(redirectTo);
}
</script>Still Not Working?
Debug guard execution order. Vue Router guards run in this order:
beforeEach(global)beforeEnter(per-route)beforeRouteEnter(in-component)
Add console.log to each guard to verify which ones are firing and in what order.
Check for multiple router instances. If your app accidentally creates two createRouter() instances, guards registered on one may not affect navigation in the other.
Verify the route meta is defined correctly:
// Check meta is accessible
console.log(to.meta); // Should show { requiresAuth: true, ... }
console.log(to.meta.requiresAuth); // Should be true, not undefinedFor Nuxt.js, use middleware instead of beforeEach:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return navigateTo('/login');
}
});
// Apply in page component:
definePageMeta({
middleware: ['auth'],
requiresAuth: true,
});For related Vue issues, see Fix: Vue Reactive Data Not Updating and Fix: Pinia Store Not Updating.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Pinia State Not Reactive — Store Changes Not Updating the Component
How to fix Pinia store state not updating components — storeToRefs for destructuring, $patch for partial updates, avoiding reactive() wrapping, getters vs computed, and SSR hydration.
Fix: Vue Computed Property Not Updating — Reactivity Not Triggered
How to fix Vue computed properties not updating — reactive dependency tracking, accessing nested objects, computed setters, watchEffect vs computed, and Vue 3 reactivity pitfalls.
Fix: Vue v-model Not Working on Custom Components — Prop Not Syncing
How to fix Vue v-model on custom components — defineModel, modelValue/update:modelValue pattern, multiple v-model bindings, v-model modifiers, and Vue 2 vs Vue 3 differences.