Skip to content

Navigation guards

Assignment

Martina Hostettler has written down two wishes.

As a volunteer at the counter, I want to sign in, so that only the team sees the area for loans and returns.

As a member, I want a warning before I leave a half-filled form, so that my input doesn't get lost.

The acceptance criteria are:

  1. Anyone who isn't signed in with a team account and opens a page of the team ends up on the login page.
  2. After the login, the app leads to the page that the person wanted to open.
  3. Anyone who wants to leave a page with a half-filled form gets asked and can stay.

Learning goals

After this chapter you can

  • describe the flow of a navigation with its guards,
  • write a global guard with beforeEach and fields in meta,
  • lead back to the original target after the login and accept only targets inside the app,
  • write a guard for a single route with beforeEnter,
  • ask before someone leaves a page with onBeforeRouteLeave,
  • explain why a guard in the browser protects no data.

Between the click on a link and the new page, the router asks a series of functions whether the navigation may go on. These functions are called navigation guards. The documentation describes the exact order, and the figure shows the steps that this course needs.

The navigation flow A sequence from top to bottom. A navigation starts with a RouterLink or router.push. Then onBeforeRouteLeave runs in the page being left, beforeEach for every navigation, beforeEnter only when a route is entered, the components are loaded and beforeResolve runs. After that the navigation is confirmed, afterEach runs and the DOM shows the new page. On the right it says that every guard cancels the navigation with false and redirects to a new navigation with a route. Navigation starts RouterLink, router.push onBeforeRouteLeave in the page being left beforeEach global, on every navigation beforeEnter only when entering the route Components load routes with import() beforeResolve global, right before confirming Navigation confirmed the address changes afterEach title, focus, stops nothing DOM updated the new page appears Guards return false navigation cancelled return { name: … } redirect, new navigation

A guard gets the target to and the previous route from. If it returns nothing, the navigation goes on. false cancels the navigation, and a route redirects to a new navigation. Older examples use a third argument, next. The documentation still supports it but advises against it, because it's a common source of bugs.

router.beforeEach runs before every navigation. Instead of a list of paths, the guard checks a field in meta that the route itself carries. According to the documentation, to.meta contains the fields of all matching routes, from the parent route down to the child route. A flag on /team therefore also applies to /team/returns.

App.vue

App.vue
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() {
member.value = null
}
</script>
<template>
<nav>
<RouterLink to="/">Catalog</RouterLink>
<RouterLink to="/team">Team</RouterLink>
<RouterLink to="/login">Sign in</RouterLink>
<button v-if="member" type="button" @click="signOut">Sign out</button>
</nav>
<p>Address: {{ route.fullPath }}</p>
<RouterView />
</template>
<style scoped>
nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
</style>

auth.js

auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-ins
export const member = ref(null)

CatalogView.vue

CatalogView.vue
<template>
<h1>Catalog</h1>
<p>60 things to borrow.</p>
</template>

LoginView.vue

LoginView.vue
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
const router = useRouter()
// The target comes from the address, so it is input from outside. Only paths inside the app count.
function target() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'
}
function signIn() {
member.value = { name: 'Martina Hostettler', team: true }
router.replace(target())
}
</script>
<template>
<h1>Sign in</h1>
<p v-if="route.query.redirect">Afterwards you go on to {{ route.query.redirect }}</p>
<button type="button" @click="signIn">Sign in as Martina Hostettler</button>
</template>

main.js

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'
import CatalogView from './CatalogView.vue'
import LoginView from './LoginView.vue'
import TeamView from './TeamView.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/team', component: TeamView, meta: { requiresTeam: true } },
],
})
// Runs before every navigation. Nothing lets it pass, a route location redirects.
router.beforeEach((to) => {
if (to.meta.requiresTeam && !member.value?.team) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
export default router

TeamView.vue

TeamView.vue
<template>
<h1>Team at the counter</h1>
<p>For the team only.</p>
</template>

Without a login, a click on "Team" leads to /login?redirect=/team. The guard passes the target along as redirect in the query, so the login page knows where to go afterwards.

A server has to check a login, and that comes in part 7. Until then, the running case app has a stand-in in src/auth.js. It knows two members, Martina Hostettler with the number M-117 and a team account, and member M-204 without one. Signing in only takes the member number, and the code contains no passwords. The module keeps the state itself, and the login is gone after a reload.

A guard in the browser protects no data. It makes sure that members don't end up on pages that aren't meant for them by accident. Anyone who changes the code in the browser gets past it. According to the OWASP Top 10:2025, access control only works in code on a server that an attacker can't modify. The server therefore has to protect the data of the team.

After the login, the page reads the target from route.query.redirect and goes there with router.replace. replace fits because the login doesn't need a step of its own in the history. The value comes from the address, though, and anyone can build an address and send it around. The OWASP cheat sheet on redirects recommends accepting only addresses of your own site for such return targets. The login therefore checks that the target starts with a single /, and otherwise it goes to the catalog.

A guard that belongs to only one route goes into that route as beforeEnter. According to the documentation, it only runs when someone enters the route, not when params, query or hash change.

App.vue

App.vue
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() {
member.value = null
}
</script>
<template>
<nav>
<RouterLink to="/">Catalog</RouterLink>
<RouterLink to="/team">Team</RouterLink>
<RouterLink to="/login">Sign in</RouterLink>
<button v-if="member" type="button" @click="signOut">Sign out</button>
</nav>
<p>Address: {{ route.fullPath }}</p>
<RouterView />
</template>
<style scoped>
nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
</style>

auth.js

auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-ins
export const member = ref(null)

CatalogView.vue

CatalogView.vue
<template>
<h1>Catalog</h1>
<p>60 things to borrow.</p>
</template>

LoginView.vue

LoginView.vue
<script setup>
import { useRouter } from 'vue-router'
import { member } from './auth.js'
const router = useRouter()
function signIn() {
member.value = { name: 'Martina Hostettler', team: true }
router.push('/team')
}
</script>
<template>
<h1>Sign in</h1>
<button type="button" @click="signIn">Sign in as Martina Hostettler</button>
</template>

main.js

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'
import CatalogView from './CatalogView.vue'
import LoginView from './LoginView.vue'
import TeamView from './TeamView.vue'
export default createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogView },
{
path: '/login',
component: LoginView,
// Runs only when someone enters this route, whoever is signed in goes straight on
beforeEnter: () => (member.value ? '/team' : true),
},
{ path: '/team', component: TeamView },
],
})

TeamView.vue

TeamView.vue
<template>
<h1>Team at the counter</h1>
<p>For the team only.</p>
</template>

Once you're signed in, "Sign in" takes you straight to the team area. For a guard that concerns only one route, beforeEnter is easier to follow than a condition in beforeEach. When a guard checks many routes, beforeEach with meta fits better.

onBeforeRouteLeave runs before someone leaves the page that the component is on. According to the documentation, this works in any component that a RouterView renders, even deep inside the page, which in the running case app is the reservation form.

App.vue

App.vue
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<nav>
<RouterLink to="/">Catalog</RouterLink>
<RouterLink to="/items/6">Reserve the projector</RouterLink>
</nav>
<p>Address: {{ route.fullPath }}</p>
<RouterView />
</template>
<style scoped>
nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
</style>

CatalogView.vue

CatalogView.vue
<template>
<h1>Catalog</h1>
<p>60 things to borrow.</p>
</template>

ItemView.vue

ItemView.vue
<script setup>
import { computed, ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const note = ref('')
const dirty = computed(() => note.value.trim() !== '')
// Asks before someone leaves the page with a half-filled form, false keeps the page
onBeforeRouteLeave(() => {
if (dirty.value && !window.confirm('Your input will be lost. Leave the page anyway?')) {
return false
}
})
</script>
<template>
<h1>Projector</h1>
<label for="note">Note on the reservation</label>
<textarea id="note" v-model="note"></textarea>
</template>

main.js

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'
import ItemView from './ItemView.vue'
export default createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogView },
{ path: '/items/:id', component: ItemView },
],
})

If the field contains something, window.confirm asks whether to really leave the page, and on "Cancel" the guard stops the navigation with false. Reloading and closing the tab aren't changes of route. For those, the browser has the beforeunload event, which this course doesn't use any further.

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. In the first example, click "Team" without signing in. Which address does the paragraph show, and what does the login page say?
  2. Let the guard return false instead of the route. What happens now when you click "Team"?
  3. In the example with beforeEnter, sign in, go back to the catalog and click "Sign in".
  4. In the example with the form, write a note and click "Catalog". Answer once with "Cancel" and once with "OK".

The exercises build the login and the warning from the assignment. You'll find the project in the course repo in the folder exercises/06-routing/guards/en. The stand-in for the login is already in src/auth.js. Copy the folder, install the packages and start the tests.

Terminal window
npm install
npm test

Exercise Getting started

Protect the team area with a global guard in src/router/index.js. Anyone who isn't signed in with a team account and wants to open a route with meta.requiresTeam ends up on the route login, and the query keeps the target as redirect.

Solution
index.js
// Runs before every navigation. Nothing lets it pass, a route location redirects.
router.beforeEach((to) => {
if (to.meta.requiresTeam && !currentMember.value?.team) {
// The login leads back to the target afterwards
return { name: 'login', query: { redirect: to.fullPath } }
}
})

currentMember comes from src/auth.js. The question mark in currentMember.value?.team covers the case where nobody is signed in and the value is null.

Exercise Going further

After the login, LoginView leads to the address from redirect, and to the catalog when there is no redirect. Only paths inside the app count, which means values that start with a single /.

Solution
LoginView.vue
// The target comes from the address, so it is input from outside. Only paths inside the app count.
function target() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'
}
function submit() {
if (!signIn(number.value)) {
error.value = 'We do not know this member number.'
return
}
router.replace(target())
}

typeof redirect === 'string' rules out a missing value, and also a query with two values, which arrives as an array. startsWith('//') catches an address without a protocol that points to another host.

Exercise Challenge

Warn before someone leaves the page of a thing while the form contains something. Ask with window.confirm, and stay on the page if the answer is "Cancel".

Solution
ItemView.vue
// Something typed into the form would be lost when leaving the page
const dirty = computed(() => Object.values(request).some((value) => value.trim() !== ''))
// Runs before the page is left, false keeps it
onBeforeRouteLeave(() => {
if (dirty.value && !window.confirm('Your input will be lost. Leave the page anyway?')) {
return false
}
})

dirty checks all fields at once. After sending, Object.assign clears the form, and the warning doesn't appear. The tests replace window.confirm with vi.stubGlobal and give the answer themselves, while in the browser the dialog appears.

MistakeWhat helps
The console reports Detected a possibly infinite redirection in a navigation guard.The guard redirects the login page itself as well. Only redirect for routes with meta.requiresTeam, and return nothing otherwise.
next gets called twice or not at all, and the navigation hangs.Return a value instead of calling next, either false, a route or nothing.
After the login you end up at / instead of the page you wanted.Pass the target along as redirect in the query and read it after the login.
The warning doesn't appear on a reload or when the tab closes.That's not a change of route. The browser has the beforeunload event for it.
An AI assistant protects the data of the team with a guard alone.A guard steers members, it doesn't protect anything. The server checks access, see part 7.

Self-check

  1. What can a guard return, and what does each value do?
  2. Why does the guard check a field in meta instead of a list of paths?
  3. Why does the login check the target from the address?
  4. Why doesn't a guard in the browser protect any data?
Answers
  1. Nothing or true lets the navigation go on, false cancels it, and a route redirects to a new navigation.
  2. The flag sits on the route itself and also applies to its child routes, and a new page of the team needs no change in the guard.
  3. Anyone can build an address with a target and send it around. Only paths of the app are safe as targets.
  4. The code runs in the browser of the person who wants to get around it. Only code on the server can check access reliably.

Assignment

Only the team gets into its area, and no form gets lost unnoticed anymore. At the end, the volunteer looks at what comes over the network on the first load. "Every member loads the code for the team area, even though they never get to see it. And a wrong address shows an empty page." The next chapter solves both.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.