Skip to the solution
MediumFrontend11 min read

What are Vue Router navigation guards?

810 views

The problem

Question presented to candidate: "Our settings pages must only be reachable when signed in, and users keep losing unsaved edits when they click away. Explain Vue Router navigation guards and show how you would solve both problems."

What a strong answer should cover:

  • The three levels: global (router.beforeEach, beforeResolve, afterEach), per-route (beforeEnter in the route record) and in-component (onBeforeRouteLeave, onBeforeRouteUpdate, and the Options API beforeRouteEnter).
  • What a guard returns: nothing or true to continue, false to cancel, a route location to redirect, or a thrown error to fail the navigation; guards can be async.
  • The resolution order (leave, global before, update, per-route, enter, resolve, after) and that afterEach cannot change the navigation.
  • Using meta (for example requiresAuth) and a global guard for auth with a redirect query, and onBeforeRouteLeave for unsaved changes.
  • next() is deprecated in Vue Router 5 (dev warning since 5.0.3); return values instead. Guards are UX, not security: the server must still check.

Clarifying questions expected:

  • "Is the auth state known synchronously, or do we have to ask the server on the first navigation?"
  • "Should the redirect after login return the user to the page they asked for?"

Code / implementation expected: Yes โ€” a beforeEach auth guard using route meta, and an onBeforeRouteLeave confirm in a form page.

  • router
  • guards

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read ยท 10 sections

Read the code

An auth guard, a per-route guard, afterEach titles and an unsaved-changes leave guard
import { createRouter, createWebHistory, type RouterHistory } from "vue-router";
import { useAuth } from "./auth";

// Tell TypeScript which meta fields routes may declare
declare module "vue-router" {
  interface RouteMeta {
    requiresAuth?: boolean;
    title?: string;
  }
}

export function makeRouter(history: RouterHistory = createWebHistory()) {
  const router = createRouter({
    history,
    routes: [
      { path: "/", component: () => import("./HomePage.vue"), meta: { title: "Home" } },
      { path: "/login", name: "login", component: () => import("./LoginPage.vue"), meta: { title: "Sign in" } },
      {
        path: "/settings",
        component: () => import("./SettingsPage.vue"),
        meta: { requiresAuth: true, title: "Settings" },
        // per-route guard: runs only when ENTERING this route from another one
        beforeEnter: (to) => {
          if (to.query.tab === "danger" && !useAuth().isAdmin) return { path: "/settings" }; // drop the tab
        },
      },
    ],
  });

  // Global guard: return nothing/true to continue, false to cancel,
  // or a location to redirect. No next() needed (it is deprecated in v5).
  router.beforeEach(async (to) => {
    const auth = useAuth();
    if (to.meta.requiresAuth && !(await auth.check())) {
      return { name: "login", query: { redirect: to.fullPath } };
    }
  });

  // After the navigation is confirmed: side effects only, cannot cancel
  router.afterEach((to, from, failure) => {
    if (!failure) document.title = `${to.meta.title ?? "App"} | Acme`;
  });

  return router;
}
Script that prints the full guard order (Node, memory history)
import "./dom.mjs"; // jsdom globals (window, document) so components can mount in Node
import { createApp, h, defineComponent, onMounted } from "vue";
import { createRouter, createMemoryHistory, RouterView, isNavigationFailure, NavigationFailureType, onBeforeRouteLeave } from "vue-router";

const log = (...a) => console.log(...a);
const Home = defineComponent({
  name: "Home",
  beforeRouteLeave(to, from) { log(" 1. beforeRouteLeave   (Home, in-component)"); },
  render: () => h("p", "home"),
});
const Account = defineComponent({
  name: "Account",
  beforeRouteEnter(to, from, next) {
    log(" 5. beforeRouteEnter   (Account, in-component, no `this` yet)");
    next((vm) => log(" 8. beforeRouteEnter next(vm) callback, vm.$options.name =", vm.$options.name));
  },
  beforeRouteUpdate() { log("    beforeRouteUpdate  (Account)"); },
  mounted() { log(" 9. Account mounted"); },
  render: () => h("p", "account"),
});
const routes = [
  { path: "/", component: Home },
  { path: "/account/:tab?", component: Account, beforeEnter: (to, from) => { log(" 4. beforeEnter        (route config)"); } },
  { path: "/admin", component: { render: () => h("p", "admin") }, meta: { requiresAdmin: true } },
  { path: "/login", component: { render: () => h("p", "login") } },
];
const router = createRouter({ history: createMemoryHistory(), routes });
let phase = "init";
router.beforeEach((to, from) => {
  if (phase === "trace") log(" 2. beforeEach         (global)");
  if (to.meta.requiresAdmin) return { path: "/login", query: { redirect: to.fullPath } };
});
router.beforeEach(() => { if (phase === "trace") log(" 3. beforeEach #2      (global, registration order)"); });
router.beforeResolve(() => { if (phase === "trace") log(" 6. beforeResolve      (global)"); });
router.afterEach((to, from, failure) => { if (phase === "trace") log(" 7. afterEach          (global) failure =", failure); });

const app = createApp({ render: () => h(RouterView) }).use(router);
await router.push("/"); app.mount("#app"); await router.isReady();
phase = "trace";
log("push('/account')");
await router.push("/account");
await new Promise(r => setTimeout(r, 10));
log("push('/account/billing')  (same component, param changes)");
phase = "upd";
await router.push("/account/billing");
phase = "x";
log("--- redirect ---");
await router.push("/admin");
log("now at", router.currentRoute.value.fullPath, "redirectedFrom", router.currentRoute.value.redirectedFrom?.fullPath);
log("--- abort ---");
const off = router.beforeEach(() => false);
const f = await router.push("/");
log("push resolved with failure:", isNavigationFailure(f, NavigationFailureType.aborted), "| still at", router.currentRoute.value.fullPath);
off();
const dup = await router.push(router.currentRoute.value.fullPath);
log("same location again:", isNavigationFailure(dup, NavigationFailureType.duplicated));
log("--- throw in guard ---");
router.onError((e) => log("router.onError:", e.message));
const off2 = router.beforeEach(() => { throw new Error("guard blew up"); });
await router.push("/").catch((e) => log("push rejected:", e.message));
off2();
log("--- async guard ---");
const off3 = router.beforeEach(async (to) => { await new Promise(r => setTimeout(r, 30)); log("  async guard done for", to.fullPath); return true; });
await router.push("/"); log("  push awaited, at", router.currentRoute.value.fullPath); off3();
process.exit(0);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 37 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

How do you handle errors in Vue (onErrorCaptured, app.config.errorHandler)?

Continue