Fix: Vue Router Navigation Guard Not Working — beforeEach and Route Guards
Part of: React & Frontend Errors
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. The next() function controls the routing pipeline: call it with no arguments to proceed, call it with a route to redirect, or call it with false to abort. Missing a next() in any branch halts navigation indefinitely because the router sits waiting for resolution that never comes.
Async operations add a second failure mode. A guard that calls fetch() and only handles the response inside .then() returns immediately, and Vue Router treats the guard as resolved (with no redirect) before the HTTP response arrives. The protected route loads, and the redirect fires later — after the user already sees the page they shouldn’t.
Redirect loops are the third common failure. A beforeEach guard that redirects unauthenticated users to /login will also intercept the navigation to /login itself. Without an explicit check to skip public routes, the guard redirects to /login, which triggers the guard again, which redirects to /login again. Vue Router detects the loop after a few iterations and throws NavigationDuplicated.
Additional causes:
- Calling
next()multiple times — callingnext()more than once in a guard causes unpredictable behavior. Only onenext()call per guard invocation. - 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.
How Other Frameworks Handle Route Guards
Route-level authentication and data loading is a universal concern, but every framework solves it differently. Understanding the alternatives clarifies what Vue Router guards are actually doing and where they can trip you up.
React Router (v6.4+) replaced its older <PrivateRoute> pattern with loader functions. A loader runs before the route component renders and can throw a redirect() to reroute the user. There is no next() callback — the loader either returns data or throws a redirect. This eliminates the “forgot to call next()” class of bugs entirely, but it means auth checks must be duplicated in every protected route’s loader unless you use a parent layout route with its own loader.
Next.js middleware runs at the edge before the page is served. You export a middleware() function from middleware.ts at the project root, and it intercepts every request matching a configured path pattern. The function receives a NextRequest and returns NextResponse.next() to proceed or NextResponse.redirect() to reroute. Because it runs on the server (or edge runtime), the auth check happens before any client-side JavaScript loads — the user never sees a flash of the protected page.
SvelteKit uses load() functions in +page.server.ts or +layout.server.ts. A server-side load function can call redirect(302, '/login') to reroute before the page renders. SvelteKit also supports handle hooks in hooks.server.ts for global request interception, similar to Express middleware. The mental model is closer to server middleware than to client-side guards.
Nuxt route middleware is the closest analog to Vue Router guards because Nuxt is built on Vue Router. You define middleware files in middleware/ and attach them to pages with definePageMeta({ middleware: ['auth'] }). The key difference from raw Vue Router is that Nuxt middleware can run on the server during SSR, catching unauthorized access before the page HTML is even generated. It also uses navigateTo() instead of next(), which is harder to misuse.
Angular route guards use classes or functions that implement CanActivate, CanDeactivate, or resolve. Each guard returns a boolean, a UrlTree (for redirect), or an Observable/Promise of either. Angular’s dependency injection system makes it easy to inject auth services into guards. The guard runs before the route activates, and returning false or a UrlTree cancels or redirects navigation without the “forgot to call next()” problem.
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,
});Watch for query parameter stripping. If your guard redirects to { name: 'Login' } without preserving query, the redirect URL is lost. Always pass query: { redirect: to.fullPath } so the login page can redirect back after authentication.
Test guards with hash mode routers. If you use createWebHashHistory() instead of createWebHistory(), the to.path value includes only the hash-based path. A whitelist that checks to.path === '/login' still works, but server-side redirects and external links may behave differently because the hash portion is never sent to the server.
Check for lazy-loaded route timing. If a route uses component: () => import('./Admin.vue') and the chunk fails to load (network error, wrong path), the navigation may fail silently before the guard even runs. Add an onError handler to the router to catch chunk loading failures:
router.onError((error) => {
if (error.message.includes('Failed to fetch dynamically imported module')) {
window.location.reload(); // Retry on chunk load failure
}
});For related Vue issues, see Fix: Vue Reactive Data Not Updating, Fix: Pinia Store Not Working, Fix: Vue Router Params Not Updating, and Fix: Vue Router 404 on Refresh.
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.