Skip to content

Fix: Vue Router Navigation Guard Not Working — beforeEach and Route Guards

FixDevs ·

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 call next() to continue navigation. If the else branch is missing a next() call, navigation hangs indefinitely.
  • Calling next() multiple times — calling next() more than once in a guard causes unpredictable behavior. Only one next() call per guard invocation.
  • Async guards without async/await — using callbacks (.then()) inside a guard and calling next() outside the callback means the navigation proceeds before the async operation resolves.
  • Redirect loop — a guard redirects to /login, but the /login route 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 calling next(). Mixing the two styles causes issues.
  • Route meta not defined — checking to.meta.requiresAuth when the route’s meta object doesn’t have that field returns undefined (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 next callback style and the return value style in Vue Router 4. Pick one approach and use it consistently. The return value style (no next parameter) 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:

  1. beforeEach (global)
  2. beforeEnter (per-route)
  3. 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 undefined

For 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles