Vue Router Navigation Guards Explained

After reading this you will be able to place the right guard at the right level in Vue Router 4 — global beforeEach/beforeResolve/afterEach, per-route beforeEnter, and the in-component guards — resolve them asynchronously for auth checks, and reason about the exact order in which they run during a single navigation.

← Back to Vue Router Configuration

Prerequisites

Core Concept

A navigation guard is a hook Vue Router calls while moving from one route to another, giving you a chance to allow, redirect, or cancel the transition before the destination renders. Guards live at three levels. Global guards registered on the router instance see every navigation: beforeEach runs first and is where authentication belongs, beforeResolve runs last before confirmation once all components are resolved, and afterEach is a passive hook that fires after the navigation commits (it cannot cancel — it is for analytics and titles). Per-route guards, declared as beforeEnter on a single route record, run only when entering that specific route. In-component guards — beforeRouteEnter, beforeRouteUpdate, and beforeRouteLeave — live inside the component and react to it being entered, reused with new params, or left. In Vue Router 4 a guard signals its decision by its return value, not the old next() callback: return true or undefined to allow, false to cancel, or a location (a path string or route-location object) to redirect. Guards may be async or return a Promise, and the router awaits them, so an auth check can hit the network before the route is allowed to render.

Implementation

The router below shows all three levels cooperating on an authenticated dashboard. beforeEach runs the async auth gate and redirects unauthenticated users; beforeEnter guards a single admin route; and an in-component beforeRouteLeave protects unsaved work.

// vue-router v4 (Vue 3)
import {
  createRouter,
  createWebHistory,
  type RouteLocationNormalized,
  type NavigationGuardNext,
} from "vue-router";

async function getSession(): Promise<{ user: string; isAdmin: boolean } | null> {
  const res = await fetch("/api/session");
  return res.ok ? ((await res.json()) as { user: string; isAdmin: boolean }) : null;
}

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/login", name: "login", component: () => import("./Login.vue") },
    {
      path: "/dashboard",
      component: () => import("./Dashboard.vue"),
      meta: { requiresAuth: true },
    },
    {
      path: "/admin",
      component: () => import("./Admin.vue"),
      meta: { requiresAuth: true },
      // Per-route guard: runs only when entering /admin.
      beforeEnter: async (to) => {
        const session = await getSession();
        // Return a location to redirect, false to cancel, true/undefined to allow.
        if (!session?.isAdmin) return { name: "login", query: { next: to.fullPath } };
        return true;
      },
    },
  ],
});

// Global guard: runs first on EVERY navigation. Ideal for auth.
router.beforeEach(async (to: RouteLocationNormalized) => {
  if (!to.meta.requiresAuth) return true; // public route, allow immediately
  const session = await getSession();     // router awaits this Promise
  if (session) return true;
  // Returning a route location cancels the current nav and starts a new one.
  return { name: "login", query: { next: to.fullPath } };
});

// Global resolve guard: runs last, after all component guards, before commit.
router.beforeResolve(async (to) => {
  // Good place to ensure lazy data or permissions are ready before render.
  if (to.meta.requiresAuth) await Promise.resolve();
  return true;
});

// Passive hook: cannot cancel; fires after navigation is confirmed.
router.afterEach((to, from) => {
  document.title = typeof to.name === "string" ? to.name : "App";
});

export default router;

The in-component guard lives inside the SFC and is the only place with access to the component instance. beforeRouteEnter cannot use this because the component is not created yet, so it exposes the instance through a callback:

// vue-router v4 (Vue 3) — inside a <script setup lang="ts"> component
import { onBeforeRouteLeave, onBeforeRouteUpdate } from "vue-router";
import { ref } from "vue";

const isDirty = ref(false);

// Fires when navigating AWAY; return false to cancel and keep the user here.
onBeforeRouteLeave((to, from) => {
  if (!isDirty.value) return true;
  return window.confirm("Discard unsaved changes?");
});

// Fires when the route changes but this component is REUSED (e.g. /user/1 -> /user/2).
onBeforeRouteUpdate(async (to) => {
  // params changed but the component stayed mounted — refetch for the new id.
  await fetch(`/api/users/${to.params.id}`);
  return true;
});

For a single navigation into /admin, the resolution order is: the leaving component’s beforeRouteLeave, then global beforeEach, then the route’s beforeEnter, then the entering component’s beforeRouteEnter, then global beforeResolve, the navigation is confirmed, and finally afterEach fires. The legacy next() callback is still accepted for compatibility, but returning a value is the idiomatic Vue Router 4 style shown above.

Verification

Assert the guard actually blocks and redirects rather than merely rendering a message. Drive a real navigation to a protected route while logged out and confirm the URL lands on /login.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("beforeEach redirects an unauthenticated user away from a protected route", async ({ page }) => {
  await page.context().clearCookies(); // ensure no session
  await page.goto("/dashboard");
  // The guard cancelled the nav and redirected; note the preserved next param.
  await expect(page).toHaveURL(/\/login\?next=%2Fdashboard/);
});

test("beforeRouteLeave cancels navigation when there are unsaved changes", async ({ page }) => {
  await page.goto("/dashboard");
  await page.getByRole("textbox").fill("edit"); // marks the form dirty
  page.on("dialog", (d) => d.dismiss());          // click "Cancel" on confirm
  await page.getByRole("link", { name: /home/i }).click();
  await expect(page).toHaveURL(/\/dashboard/);     // stayed put
});

For a manual check, add a console.log at the top of each guard, then navigate into /admin and read the console: the log order should match the resolution flow above. If beforeEach never logs on a client-side navigation, the guard was registered after the first render or on the wrong router instance.

Gotchas

  • A guard that returns nothing on some code paths implicitly returns undefined, which allows the navigation — forgetting a return false or return { name: 'login' } in one branch silently lets a protected route through.
  • beforeRouteEnter runs before the component exists, so this and reactive refs are unavailable; use its callback form next(vm => …) to touch the instance once it is created, or move the logic to beforeRouteUpdate.
  • afterEach cannot cancel or redirect — returning a location from it does nothing. Reserve it for side effects like titles and analytics, and keep all gating logic in the before-guards.
  • Awaiting a slow network call inside beforeEach blocks every navigation until it resolves; cache the session or resolve it optimistically so routine in-app navigations do not pay a round-trip each time.

FAQ

What order do Vue Router navigation guards run in? For a full navigation the order is: leaving component beforeRouteLeave, global beforeEach, per-route beforeEnter, entering component beforeRouteEnter, global beforeResolve, then the navigation is confirmed, and finally afterEach fires as a passive hook.

How do I cancel or redirect a navigation in Vue Router 4? Return a value from the guard. Return false to cancel and stay on the current route, return a location such as a path string or a route object to redirect, and return true or undefined to allow the navigation to continue.

Do I still need to call next() in Vue Router 4? No. Vue Router 4 lets a guard signal its decision by its return value, which is the idiomatic style. The next() callback is still accepted for backward compatibility, but mixing return values and next() in the same guard causes the navigation to resolve twice.

Where should authentication checks live? In the global beforeEach guard, gated on a route meta field such as requiresAuth. It runs first on every navigation, can await an async session check, and can redirect to the login route while preserving the intended destination in a query parameter.

Why can’t I use this inside beforeRouteEnter? Because the guard runs before the component instance is created, so there is no this to reference yet. Access the instance through the guard’s callback argument, which runs after creation, or move instance-dependent logic into beforeRouteUpdate or an onMounted hook.