Vue – Route Guards and Navigation

December 5, 20254 min readUpdated 8/24/2026

A guard runs before a navigation completes and can allow it, redirect it, or cancel it. It is how you keep people out of pages they are not allowed to see, and how you stop them leaving a page with unsaved work.

A global guard

The demo application's entire admin authorisation is nine lines:

router.beforeEach((to) => {
  const auth = useAuthStore();

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    // Remember where they were headed so login can bounce them back.
    return { name: "admin-login", query: { redirect: to.fullPath } };
  }

  if (to.meta.requiresAdmin && !auth.isAdmin) {
    return { name: "admin-dashboard" };
  }

  return true;
});

Read the return values, because they are the whole API:

Return true or nothing — allow the navigation.

Return a route location — redirect there instead.

Return false — cancel it, and the URL stays where it was.

Vue Router 4 uses return values rather than the next() callback Vue Router 3 used. next() still works and is deprecated; if you see a tutorial calling next() three times in one guard — a classic source of "the navigation happened twice" bugs — it predates this.

Preserving where they were going

The redirect carries query: { redirect: to.fullPath }, and the login view spends it:

async function submit() {
  try {
    await auth.login(email.value, password.value);
    // Honour ?redirect= so a deep link into the admin survives the login hop.
    router.push(route.query.redirect ?? { name: "admin-dashboard" });
  } catch {
    /* auth.error is rendered below */
  }
}

Someone who clicks a link to /admin/reels/abc123 while signed out lands on the login page and is returned to that exact reel afterwards, rather than being dumped on the dashboard to find their way back. It is a few extra characters and it is the difference between an application that feels considered and one that does not.

Guards are convenience, not security

Worth being blunt about. Everything in a guard runs in the browser, where the user can change it. The guard's job is to avoid showing a page that will fail; the server's job is to refuse the data. The demo application enforces the same roles on the backend, and the comment in its auth store says exactly that — the client-side check exists so the UI does not offer buttons that are going to come back 403.

meta drives it

The guard reads to.meta, and the route table declares intent:

    path: "/admin",
    component: AdminLayout,
    meta: { requiresAuth: true },
    children: [
      {
        path: "",
        name: "admin-dashboard",
        component: () => import("../views/admin/DashboardView.vue"),
        meta: { title: "Dashboard" },
      },
      {
        path: "reels",
        name: "admin-reels",
        component: () => import("../views/admin/ReelListView.vue"),
        meta: { title: "Reels" },
      },
      {
        path: "reels/new",
        name: "admin-reel-new",
        component: () => import("../views/admin/ReelEditView.vue"),
        meta: { title: "New reel" },
      },
      {
        path: "reels/:id",
        name: "admin-reel-edit",
        component: () => import("../views/admin/ReelEditView.vue"),
        meta: { title: "Edit reel" },
      },
      {
        path: "collections",
        name: "admin-collections",
        component: () => import("../views/admin/CollectionListView.vue"),
        meta: { title: "Collections" },
      },
      {
        path: "creators",
        name: "admin-creators",
        component: () => import("../views/admin/CreatorListView.vue"),
        // ADMIN only: a creator has no business renaming other creators.
        meta: { title: "Creators", requiresAdmin: true },
      },
    ],
  },

meta is merged from the whole matched chain, so requiresAuth on the parent covers every child without being repeated. That is what makes this approach scale: a page added under /admin is protected by default, and forgetting to protect it is not a thing you can do.

Compare with putting the check in each view's onMounted — a check per page, running after the component has already mounted and possibly fetched, and one careless merge away from being missed.

afterEach

Runs after a navigation is confirmed. It cannot change anything, which makes it right for side effects:

router.afterEach((to) => {
  document.title = to.meta.title ? `${to.meta.title} · ReelCMS` : "ReelCMS";
});

The document title, in one place, driven by the same meta the guard reads. This is also where analytics page views belong.

The other guards

Per-route, when only one route needs it:

{
  path: "/admin/reels/:id",
  component: ReelEditView,
  beforeEnter: (to) => {
    if (!/^[a-f0-9]{24}$/.test(to.params.id)) return { name: "not-found" };
  },
}

In-component, for the case that actually matters — leaving a page with unsaved changes:

<script setup>
import { onBeforeRouteLeave } from "vue-router";

onBeforeRouteLeave(() => {
  if (!isDirty.value) return true;
  // Returning false cancels the navigation and the URL does not change.
  return window.confirm("You have unsaved changes. Leave anyway?");
});
</script>

A guard may return a promise, so a real application answers this with its own modal rather than window.confirm — resolve the promise with the user's choice.

Note this only covers navigation within the app. Closing the tab or typing a new URL is the browser's beforeunload event, which is a separate thing you have to add yourself.

onBeforeRouteUpdate is the third: it fires when the route changes but the component is reused — /r/one to /r/two. Same problem the watcher in lesson 19 solves, and either answer is fine.

Order of execution

For a full navigation:

onBeforeRouteLeave      (component being left)
beforeEach              (global)
beforeEnter             (route)
setup / component created
beforeResolve           (global, after all component guards)
--- navigation confirmed ---
afterEach               (global)

Async guards

A guard can be async, and the navigation waits:

router.beforeEach(async (to) => {
  const auth = useAuthStore();
  // Only on the first navigation -- restore a session from a refresh token
  // before deciding whether this person is allowed in.
  if (!auth.ready) await auth.restore();
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: "admin-login", query: { redirect: to.fullPath } };
  }
});

Keep these fast, and make sure every path returns. A guard that awaits something slow leaves the user on the old page with nothing happening — no spinner, because no navigation has started. A guard that throws or never resolves aborts the navigation silently, which is one of the more baffling things that can happen to a Vue application.

Next: Forms and Validation.