Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 019 / 100

Navigation guards: Vue & Nuxt best practices explained

Protect routes and confirm leaving.

Expona Admin8 min read

Introduction

Navigation guards are one of the most powerful but often misunderstood features in Vue Router. They let you intercept route transitions, validate permissions, confirm unsaved changes, and redirect users before they land on a new page. In this article, we'll cover navigation guards: Vue & Nuxt best practices explained, with real production patterns that keep your routing secure and user-friendly.

What are navigation guards?

Navigation guards are middleware functions that run before, during, or after a route change. Think of them as checkpoints: a user tries to navigate to /admin, a guard runs to check if they're authenticated, and either lets them through or sends them elsewhere. Vue Router provides several types: global guards, per-route guards, and in-component guards. Each serves a different purpose in your routing strategy.

In Nuxt, guards work similarly but integrate with the framework's file-based routing. You can use middleware in the middleware/ directory and apply it to pages declaratively, which often feels cleaner than scattered guard logic.

beforeEach for auth checks

The most common use case is protecting routes that require authentication. A global beforeEach guard runs on every route transition, making it ideal for checking if the user is logged in before they reach a protected page.

Here's how we typically set it up in a Vue Router app:

typescript
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: Login },
    { path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
    { path: '/admin', component: Admin, meta: { requiresAuth: true, role: 'admin' } }
  ]
});

router.beforeEach((to, from, next) => {
  const isAuthenticated = !!localStorage.getItem('authToken');
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth);

  if (requiresAuth && !isAuthenticated) {
    // User is not logged in; redirect to login
    next('/login');
  } else if (to.path === '/login' && isAuthenticated) {
    // User is already logged in; redirect away from login
    next('/dashboard');
  } else {
    // Allow the navigation
    next();
  }
});

app.use(router);

The meta field on each route lets us tag which routes need protection. Inside the guard, we check the token in localStorage, compare it against the route requirements, and either allow or block the transition. This pattern scales well: you can add role-based checks, subscription tiers, or feature flags all in one place.

In Nuxt, you'd use middleware instead:

typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const isAuthenticated = !!useAuthStore().token;
  
  if (!isAuthenticated) {
    return navigateTo('/login');
  }
});

// app.vue or any page
definePageMeta({
  middleware: 'auth'
});

Nuxt's approach is cleaner because the middleware declaration sits right on the page or layout, making dependencies visible. You also have access to composables like useAuthStore() directly, avoiding the need to manage state manually.

Return a redirect location

Navigation guards can redirect users in several ways. The most flexible is returning a location object or string, which tells the router where to send the user instead of continuing to the originally requested route.

Let's say you have a role-based system. A user tries to visit /admin but they're only a moderator. Instead of showing a 404, you redirect them to their permitted dashboard:

typescript
router.beforeEach((to, from, next) => {
  const user = useAuthStore().user;
  const isAuthenticated = !!user;

  if (to.meta.requiresAuth && !isAuthenticated) {
    next('/login');
    return;
  }

  if (to.meta.requiredRole && user?.role !== to.meta.requiredRole) {
    // Redirect based on their actual role
    const fallback = user?.role === 'admin' ? '/admin-home' : '/user-home';
    next(fallback);
    return;
  }

  next();
});

Returning false from a guard cancels navigation entirely, which is useful if you want to show an in-place confirmation dialog. Returning a named route or path object gives you fine control over where they land:

typescript
// Redirect to a named route with query params
next({ name: 'login', query: { redirect: to.fullPath } });

// Or a plain path
next('/upgrade-required');

In production, we often use query parameters to remember where the user came from. After they log in, you can redirect them back to their original destination, improving the user experience significantly.

beforeRouteLeave for unsaved changes

Sometimes you need to prevent users from leaving a page without saving their work. The beforeRouteLeave guard runs when the user tries to navigate away from the current route, giving you a chance to warn them or block the transition.

Here's a real-world example: an editor page with a form that tracks dirty state:

typescript
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

export default {
  setup() {
    const router = useRouter();
    const route = useRoute();
    const isDirty = ref(false);
    const formData = ref({ title: '', content: '' });

    watch(formData, () => {
      isDirty.value = true;
    }, { deep: true });

    const saveChanges = async () => {
      await api.post('/articles', formData.value);
      isDirty.value = false;
    };

    // Per-component guard
    router.beforeRouteLeave((to, from, next) => {
      if (isDirty.value) {
        const confirmed = window.confirm(
          'You have unsaved changes. Leave anyway?'
        );
        if (confirmed) {
          next();
        } else {
          next(false); // Cancel navigation
        }
      } else {
        next();
      }
    });

    return { formData, isDirty, saveChanges };
  }
};

A simpler and more modern approach uses the <script setup> syntax with the onBeforeRouteLeave composable:

typescript
<script setup>
import { ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';

const isDirty = ref(false);
const formData = ref({ title: '', content: '' });

watch(formData, () => {
  isDirty.value = true;
}, { deep: true });

onBeforeRouteLeave((to, from) => {
  if (isDirty.value) {
    return confirm('You have unsaved changes. Leave anyway?');
  }
});
</script>

In Nuxt, you'd use the middleware or a direct route guard. The composable approach is cleaner because it keeps the logic in the component that owns the state.

Keep guards fast

Navigation guards run synchronously in the critical path. A slow guard blocks the entire route transition, freezing the UI. This is why performance matters enormously in production.

Here are patterns we've learned the hard way:

  • Cache auth state in a store: Don't fetch user data on every route change. Store it in Pinia or similar and update only when needed.
  • Use async guards sparingly: If you must run an async check, return a Promise and handle errors gracefully. A failed API call can block navigation indefinitely.
  • Avoid complex logic: If your guard needs five nested conditions, refactor it into a utility function or split the logic across multiple guards.
  • Prefetch data early: Load user permissions when the app initializes, not during navigation.

Here's an example of an async guard done carefully:

typescript
router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore();

  // If already loaded, skip the API call
  if (authStore.isInitialized) {
    next();
    return;
  }

  try {
    // Load user once at app start
    await authStore.fetchUser();
    next();
  } catch (error) {
    // On error, redirect to login or error page
    console.error('Auth fetch failed:', error);
    next('/login');
  }
});

// In your main app initialization
router.beforeEach(authGuard);

The key is the isInitialized flag. Once user data is loaded, we don't fetch it again. For subsequent navigation, the guard is nearly instant.

Common mistakes

Infinite redirect loops: If your guard at route A redirects to route B, and route B's guard redirects back to A, the router gets stuck. Always ensure your guard logic has a termination point. Test edge cases like logging out and logging back in.

Forgetting to call next(): In older Vue Router versions, if you don't call next(), navigation hangs. Modern versions are more forgiving, but it's a habit worth keeping. Always have a clear path through your guard logic.

Storing auth tokens in localStorage alone: Guards check localStorage for tokens, but tokens can be stolen via XSS. Always validate tokens on the server side. A guard is a first-line defense, not the only one.

Making guards too broad: A guard that runs on every route and does heavy lifting will slow your entire app. Use the meta field to target only the routes that need protection.

Not handling loading states: During an async guard, the user sees no feedback. Consider showing a loading bar or spinner while the guard runs, so the UI doesn't feel frozen.

Key takeaways

  • Use beforeEach global guards to protect routes, check authentication, and redirect users based on their role or permissions.
  • Leverage route meta fields to declaratively mark which routes need protection, keeping guard logic clean and centralized.
  • Use beforeRouteLeave or the onBeforeRouteLeave composable to warn users about unsaved changes before they navigate away.
  • Return a location object or string from a guard to redirect the user; returning false cancels the navigation entirely.
  • Keep guards fast by caching auth state, avoiding unnecessary async calls, and prefetching data early in the app lifecycle.
  • In Nuxt, prefer middleware in the middleware/ directory and apply it declaratively with definePageMeta for cleaner, more maintainable code.
  • Test edge cases: logging in, logging out, expired tokens, and redirects after login to avoid infinite loops or broken flows.
  • Always validate tokens and permissions on the server side; guards are a UX tool, not a security boundary.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles