Skip to main content

Navigation guards — blocking and redirecting navigation

Chapter 15 built the mapping from URLs to components. This chapter intercepts the navigation between them. A guard is a function that blocks navigation or diverts it elsewhere. Checking authentication, warning about unsaved changes, and updating the page title are the common uses.

There are three kinds.

KindWhere it is writtenScope
Globalrouter.beforeEach and friendsEvery navigation
Per-routebeforeEnter in the route definitionEntering that route
In-componentonBeforeRouteLeave and friendsNavigation involving that component

What you'll learn in this chapter​

  • The order guards are called in, and where afterEach sits within it
  • How to block and divert with return, and where next() stands today
  • The condition for avoiding infinite redirects when diverting
  • How meta is merged from parent to child
  • Where an exception thrown in a guard ends up

Blocking and redirecting​

A global guard receives to and from and decides the outcome by its return value.

What is returnedWhat happens
Nothing (undefined / true)It goes through
falseNavigation stops and you stay where you were
A route location ({ name: 'login' }, etc.)The current navigation is discarded and one to there is created instead
An Error instanceNavigation stops and it reaches router.onError()

A throw likewise stops navigation and reaches the handler registered with router.onError().

afterEach receives a third parameter, failure. It is called even when you stopped navigation by returning false, so work assuming "we navigated" fires blank unless you check it.

Whether it is called varies with how you stopped (measured).

How it stoppedafterEach
Returning falseCalled (with failure)
throwNot called
Returning a route location to divertNot called for the discarded navigation. Called only for the recreated one
src/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from './stores/auth'

export function registerGuards(router: Router) {
router.beforeEach((to) => {
const auth = useAuthStore()

if (!to.meta.requiresAuth) return
if (auth.isAuthenticated) return

// unless the redirect target is excluded from the condition, entering it diverts again
return { name: 'login', query: { redirect: to.fullPath } }
})

// without checking the third parameter, the title changes even for an aborted navigation
router.afterEach((to, _from, failure) => {
if (failure) return
document.title = typeof to.meta.title === 'string' ? to.meta.title : 'My App'
})
}

The condition in the comment is essential. Unless you exclude the redirect target itself, navigating to it diverts again. The code above checks !to.meta.requiresAuth first because the login screen does not carry requiresAuth.

Write a guard that returns a location unconditionally and the development build emits the diagnostic Detected a possibly infinite redirection in a navigation guard and aborts the navigation (measured). That detection is development-build only, though. The relevant part of the implementation sits inside process.env.NODE_ENV !== "production", and the official message itself says "This might break in production if not fixed".

The detection condition has latitude too. It only trips when the redirect target is the same location every time. The code above attaches query: { redirect: to.fullPath }, so if you did put requiresAuth on the login screen, the target would differ every time and it would go undetected even in a development build.

Where next() stands now​

The older form received a third parameter, next, and communicated the outcome through next() / next(false) / next('/login'). The official guide says it was error-prone and was marked for removal through an RFC, but that it still works.

Actually using it in a development build of vue-router 5.2.0, though, emits a deprecation diagnostic.

[VUE_ROUTER_R0025] The `next()` callback in navigation guards is deprecated.
├▶ fix: Return the value instead: `next()` becomes `return`, `next(false)` becomes `return false`, `next("/path")` becomes `return "/path"`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next

The awkwardness of next is that it must be called exactly once. The official guide gives this as a bad example.

// bad: next is called twice when unauthenticated
router.beforeEach((to, from, next) => {
if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
next()
})

Measured, the development build discards the second call and only the first takes effect. The diagnostic ends like this.

[VUE_ROUTER_R0024] The "next" callback was called more than once in one navigation guard when going from "/" to "/admin". This will fail in production.
├▶ fix: Call `next()` exactly once per guard: remove the extra call, or migrate to returning the value you passed to `next()`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next

"Will fail in production" is the point. Discarding the second call happens in a development-build wrapper; in a production build the raw next is passed through. next() / next(false) / next(location) do no real harm because the internal Promise settles only once, but calling next(callback) twice stacks the callback twice.

Forgetting to call it has the same shape. Declare the third parameter, never call it, and return a value instead, and the development build emits a diagnostic and aborts with an Invalid navigation guard error (measured). The returned value is not used. A production build has no such abort and the navigation hangs unresolved.

"Production build" here means process.env.NODE_ENV being production. Bundler-targeted artifacts strip that branch statically, but the Node-targeted .cjs reads it at runtime, so forgetting to set the environment variable gives you development-build behaviour in a production artifact.

The return form has neither the forgotten call nor the double call. Standardise new code on return.

The order they are called in​

The official guide lays out a 12-step resolution flow. Marking each step and measuring gives this. Steps 4 and 11 alone were measured with a different navigation and a different test.

StepWhat is calledHow it was measured
1Navigation triggeredCannot be marked
2beforeRouteLeave on the leaving sideLogged inside the guard
3Global beforeEachAs above
4beforeRouteUpdate on a reused componentLogged on a different navigation
5beforeEnter in the route definitionLogged inside the guard
6Resolving async route componentsLogged inside the loader function
7beforeRouteEnter on the entering sideLogged inside the guard
8Global beforeResolveAs above
9Navigation confirmedCannot be marked
10Global afterEachLogged inside the guard
11DOM updatesChecked for element presence inside afterEach and the callback
12The callback passed to beforeRouteEnterLogged inside the callback

The eight steps observable in one navigation (2 / 3 / 5 / 6 / 7 / 8 / 10 / 12) were called in that order.

On a navigation that mounts a new component, its element is not yet in the DOM at afterEach. Measured, it was not found inside afterEach and was found inside the step-12 callback. Updating the page title here is fine, but work that "measures the rendered DOM" fires blank.

A navigation that reuses the component is different. When only a parameter changes, as from /user/1 to /user/2, the element stays in the DOM and is visible from afterEach. Its contents are still stale, though, so you cannot measure there either.

The step-12 callback has its own caveat. Measured, the callback runs before the component's onMounted. It is already in the DOM, but onMounted has not happened. Code in the callback that assumes otherwise breaks.

The split between beforeRouteUpdate and beforeRouteLeave was measured too. On a navigation where only a parameter changes, beforeRouteUpdate is called and beforeRouteLeave is not. The same component is reused, for the same reason as the "not rebuilt" story in chapter 15.

In-component guards​

Two are available from <script setup>.

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

const original = ref('')
const draft = ref('')

onBeforeRouteLeave(() => {
if (draft.value === original.value) return
// returning false stops the navigation
return window.confirm('You have unsaved changes. Leave anyway?')
})
</script>

<template>
<textarea v-model="draft" />
</template>

onBeforeRouteUpdate takes the same shape and can be used for refetching when a parameter changes. It is not interchangeable with the watch(..., { immediate: true }) from chapter 15, though. beforeRouteUpdate is not called on the first navigation (measured), so the initial fetch has to be written separately. In its favour, it can block navigation, which watch cannot.

The third, beforeRouteEnter, has no corresponding function. The entering component has not been created yet, so it cannot be written inside <script setup>. Where needed, write it with the Options API and receive the instance in the next(vm => ...) callback (this form triggers the deprecation diagnostic from the previous section).

The diagnostic appears when next is called, not when the third parameter is declared. In fact you can return a function with only two parameters and still receive the instance, without a diagnostic (measured). The published return type does not include a function, though, so TypeScript rejects it.

The straightforward alternative is reading useRoute() inside onMounted. For most cases that is enough.

Per-route guards and meta​

beforeEnter goes in the route definition. You can write several as an array, called in the order written (measured).

There are two routes on which it is not called. Both are when that route record is reused (measured).

  • Navigation where only a parameter, query, or hash changes — it is not called from /user/1 to /user/2 (the parameter case was measured; the query and hash cases come from the official guide)
  • Movement between children sharing the same parent — a beforeEnter on the parent is not called from /admin/users to /admin/posts
{
path: '/admin',
component: AdminLayout,
meta: { requiresAuth: true, title: 'Admin' },
beforeEnter: [checkAuth, checkAdminRole],
}

meta is a free-form container, read from guards through to.meta. Two measured properties.

  • It is merged from parent to child. The child overrides the parent for the same key, and keys only the parent has survive
  • Each record's own meta remains in route.matched. Look there when you need the pre-merge values

In TypeScript, extending RouteMeta gives to.meta.requiresAuth a type.

src/meta.d.ts
import 'vue-router'

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

Registration order​

Global guards are called in the order they were registered (measured). Express dependencies such as "role check after auth check" through registration order.

beforeResolve runs on every navigation like beforeEach, but after all in-component guards on the entering side and all async component resolution have finished. The official guide describes it as suited to work you would rather avoid if the user cannot enter the page, such as fetching data.

Summary​

  • Guards come in three kinds: global, per-route, and in-component
  • A global guard's outcome comes from its return value. Return nothing and it goes through, false stops it, and a route location diverts there. Returning an Error or throwing stops it and reaches router.onError()
  • afterEach is called even for a navigation stopped with false. Without checking the third parameter, failure, the title changes without navigating. It is not called when stopped by throw, nor for the discarded side of a diverted navigation
  • Unless the redirect target itself is excluded from the condition, you get an infinite redirect. The diagnostic and abort are development-build only, and only trip when the target is the same location every time
  • The third parameter, next, still works, but the development build emits a deprecation diagnostic. The diagnostic appears when next is called. Discarding a second call and rescuing a forgotten one are development-build only; in production you get a double-stacked next(callback) or a hung navigation. The return form is free of these constraints
  • The official resolution flow has 12 steps. The eight observable in one navigation were called in that order. On a navigation that mounts a new component, its element is not yet in the DOM at afterEach. On a reusing navigation it is there, but its contents are stale
  • The callback passed to beforeRouteEnter runs before onMounted, though it is already in the DOM at that point
  • On a navigation where only a parameter changes, beforeRouteUpdate is called and beforeRouteLeave is not. beforeRouteUpdate is not called on the first navigation, so it does not replace chapter 15's watch(..., { immediate: true })
  • Two are available from <script setup>: onBeforeRouteLeave and onBeforeRouteUpdate. beforeRouteEnter cannot be written there because the entering side does not exist yet. Writing it with the Options API lets you receive the instance through next(vm => ...), but triggers the deprecation diagnostic. Returning a function with two parameters also receives it, but fails in TypeScript because it is not in the published type. The straightforward alternative is reading useRoute() inside onMounted
  • beforeEnter accepts an array, called in the order written. It is not called for parameter-only changes, nor for movement between children sharing a parent
  • meta is merged from parent to child, with the child winning on the same key. Pre-merge values remain on each record in route.matched
  • Global guards are called in registration order
  • beforeResolve runs after all in-component guards and async component resolution have finished
Related references