Navigation Guards

Global, per-route and in-component guards, an auth guard that waits for the session, and how to avoid a redirect loop.

Syntaxrouter.beforeEach(async (to, from) => { /* return void | false | RouteLocationRaw */ })

A navigation guard is a function the router calls while a navigation is in progress, and it decides what happens next. Return nothing or true and the navigation continues. Return false and it is cancelled. Return a location and the router redirects there. Guards may be async: the navigation waits for the promise, so the old screen stays on display until it settles.

Three places to put one

Global guards are registered on the router: router.beforeEach((to, from) => ...) runs for every navigation, router.beforeResolve runs after in-component guards and lazy components have resolved, and router.afterEach((to, from, failure) => ...) runs once it is over and cannot change anything. A per-route beforeEnter on a record runs only when entering that record, not when its parameters change. In-component, onBeforeRouteLeave and onBeforeRouteUpdate are called in <script setup>; they see the component's own state, which is what makes an unsaved-changes prompt possible.

Waiting for the session

An auth guard that reads a flag which has not loaded yet redirects a signed-in user to the login screen on first load. Give the session a promise that resolves once it is known, await it at the top of the guard, then decide.

The redirect loop

If the guard redirects anything unauthenticated to /login, and /login is itself unauthenticated, the guard redirects it again. Vue Router stops after too many redirects and logs an error, and the screen never changes. Always return nothing for the destination you redirect to, usually by checking to.meta.requiresAuth rather than by inverting a condition.

Older code calls a third argument, next. next(), next(false) and next('/login') mean the same as returning nothing, false or that location. It still works and must be called exactly once; returning a value is the current form.

Example

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const draft = ref('')
const saved = ref('')

function save(): void {
  saved.value = draft.value
}

onBeforeRouteLeave(() => {
  if (draft.value === saved.value) {
    return true
  }

  // false cancels the navigation and the URL stays put
  return window.confirm('Leave without saving?')
})
</script>

<template>
  <form @submit.prevent="save">
    <label for="draft">Note</label>
    <textarea id="draft" v-model="draft" rows="4"></textarea>
    <button type="submit">Save</button>
    <p v-if="draft !== saved">Unsaved changes</p>
  </form>
</template>

<style scoped>
form {
  display: grid;
  gap: 0.5rem;
  max-width: 24rem;
}
</style>

When to use it

  • Keep unauthenticated visitors out of an admin area without flashing it on screen first
  • Warn before leaving a half-filled form
  • Set the document title and send a page view once a navigation is confirmed

More examples

An auth guard that waits for the session

The guard is async, so the navigation pauses until the session is known instead of guessing. The early return covers both safe cases at once, which is what keeps the login route out of a loop: it has no requiresAuth, so it is never redirected. to.meta is merged from every matched record, so marking a parent record is enough for all of its children. Augmenting RouteMeta once, in a single file, gives every guard typed meta fields; declare it twice and the two declarations merge, so keep the fields in one place.

Example · ts
// src/router/auth-guard.ts
import type { Router } from 'vue-router'
import { useSession } from '../composables/useSession'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
  }
}

export function installAuthGuard(router: Router): void {
  const { isSignedIn, whenReady } = useSession()

  router.beforeEach(async (to) => {
    // the session may still be loading on a cold start
    await whenReady()

    if (!to.meta.requiresAuth || isSignedIn.value) {
      return
    }

    return { name: 'login', query: { redirect: to.fullPath } }
  })
}

A guard on one record only

beforeEnter keeps a rule next to the record it belongs to, instead of growing a global guard full of path comparisons. It runs only when entering the record, so it does not fire when a parameter on the same record changes. It also accepts an array of functions, which is how a shared check is reused across records.

Example · ts
// src/router/reports.ts
import type { RouteRecordRaw } from 'vue-router'
import { useSession } from '../composables/useSession'

export const reportRoutes: RouteRecordRaw[] = [
  {
    path: '/reports/export',
    name: 'report-export',
    meta: { requiresAuth: true, title: 'Export' },
    component: () => import('../views/ReportExportView.vue'),
    beforeEnter: () => {
      const { role } = useSession()

      if (role.value !== 'admin') {
        return { name: 'reports' }
      }
    },
  },
]

After the navigation is over

afterEach cannot change the navigation, which makes it the right hook for side effects that should follow a confirmed one: the document title, an analytics event, closing a mobile menu. It receives a third argument that is set when the navigation failed, and skipping those keeps the title honest. The meta.title field is typed by the single RouteMeta augmentation shown with the auth guard.

Example · ts
// src/router/title-guard.ts
import type { Router } from 'vue-router'

export function installTitleGuard(router: Router): void {
  router.afterEach((to, from, failure) => {
    if (failure) {
      return
    }

    document.title = to.meta.title ?? 'Dashboard'
  })
}

Discussion

  • Be the first to comment on this lesson.