---
title: "Navigation guards: protecting routes the right way · vuejs.school"
description: "Vue Router guards that send visitors to login and back, warn about unsaved changes and set page titles, without redirect loops or a false sense of security."
canonical_url: "https://vuejs.school/community/blog/navigation-guards-protecting-routes"
meta:
  "og:description": "Vue Router guards that send visitors to login and back, warn about unsaved changes and set page titles, without redirect loops or a false sense of security."
  "og:image": "https://vuejs.school/_og/d/a_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXk%2Cdescription_~VnVlIFJvdXRlciBndWFyZHMgdGhhdCBzZW5kIHZpc2l0b3JzIHRvIGxvZ2luIGFuZCBiYWNrLCB3YXJuIGFib3V0IHVuc2F2ZWQgY2hhbmdlcyBhbmQgc2V0IHBhZ2UgdGl0bGVzLCB3aXRob3V0IHJlZGlyZWN0IGxvb3BzIG9yIGEgZmFsc2Ugc2Vuc2Ugb2Ygc2VjdXJpdHku%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9uYXZpZ2F0aW9uLWd1YXJkcy1wcm90ZWN0aW5nLXJvdXRlcyI%2Cs_2RKgGWDELLXnPHpZ.png"
  "og:image:alt": "Navigation guards: protecting routes the right way · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Navigation guards: protecting routes the right way · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXk%2Cdescription_~VnVlIFJvdXRlciBndWFyZHMgdGhhdCBzZW5kIHZpc2l0b3JzIHRvIGxvZ2luIGFuZCBiYWNrLCB3YXJuIGFib3V0IHVuc2F2ZWQgY2hhbmdlcyBhbmQgc2V0IHBhZ2UgdGl0bGVzLCB3aXRob3V0IHJlZGlyZWN0IGxvb3BzIG9yIGEgZmFsc2Ugc2Vuc2Ugb2Ygc2VjdXJpdHku%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9uYXZpZ2F0aW9uLWd1YXJkcy1wcm90ZWN0aW5nLXJvdXRlcyI%2Cs_2RKgGWDELLXnPHpZ.png"
  "twitter:image:alt": "Navigation guards: protecting routes the right way · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What a guard returns

A navigation guard is a function the router runs while it is deciding whether to move from one route to another. It receives `to`, the route being entered, and `from`, the route being left. What you return decides what happens:

- Return nothing, or `true`: the navigation continues.
- Return `false`: the navigation is cancelled and the URL stays where it was.
- Return a route location, such as `'/login'` or `{ name: 'login' }`: the router goes there instead.

You may have seen a third argument called `next`. It still exists for old code, but the docs recommend the return style, and mixing the two in one guard is a bug. This post never uses `next`.

A guard may also be `async`. If the sign-in state has to be loaded from the server before you can decide, return a promise and the router waits for it before it moves on. A slow guard is a slow navigation, though, so load the session once, keep it in memory, and let later guards read the cached answer.

Guards live in three places:

- On the router: `beforeEach`, `beforeResolve` and `afterEach`.
- On a route record: `beforeEnter`.
- Inside a page component: `onBeforeRouteLeave` and `onBeforeRouteUpdate`.

You will use four of them: `beforeEach` for sign-in, `beforeEnter` for an admin role, `onBeforeRouteLeave` for unsaved changes and `afterEach` for page titles.

## Mark the routes that need sign-in

A route record can carry a `meta` object with anything you like. Use it to say which routes need a signed-in user. This is the router file that the later guards are added to:

*src/router/index.ts*

```
import { createRouter, createWebHistory } from 'vue-router'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', name: 'home', component: () => import('../pages/HomePage.vue') },
    { path: '/login', name: 'login', component: () => import('../pages/LoginPage.vue') },
    {
      path: '/account',
      name: 'account',
      component: () => import('../pages/AccountPage.vue'),
      meta: { requiresAuth: true, title: 'Account' },
    },
  ],
})
```

For TypeScript to know about `requiresAuth`, declare it once. Vue Router exposes a `RouteMeta` interface for exactly this:

*src/router/meta.d.ts*

```
import 'vue-router'

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

Keep the `import 'vue-router'` line: it makes the file a module, so `declare module` adds to Vue Router's types instead of replacing them.

Now `to.meta.requiresAuth` is typed as `boolean | undefined` everywhere, and `meta: { requiresAuth: 'yes' }` on a route is a compile error. A misspelt key still compiles, though. `RouteMeta` accepts any other key as `unknown`, so a typo like `to.meta.requiresAuht` is a silent `undefined` at runtime.

## Send visitors to login and back

Keep the sign-in state in a small module that the router and the login page both import. A plain `ref` is enough for this post; a store works the same way.

*src/auth.ts*

```
import { ref } from 'vue'

export const isSignedIn = ref(false)
export const isAdmin = ref(false)
```

Add the guard to `src/router/index.ts`, below `createRouter`, with the import at the top of the file. The important line is the `query`: it stores the full path of the page the person wanted, so the login page can send them back.

*src/router/index.ts*

```
import { isSignedIn } from '../auth'

router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isSignedIn.value) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})
```

`to.fullPath` includes the query string and the hash, so `/account?tab=billing` survives the round trip. On the login page, read it back after a successful sign-in:

*src/pages/LoginPage.vue*

```
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { isSignedIn } from '../auth'

const route = useRoute()
const router = useRouter()

async function signIn() {
  // call your API here, then:
  isSignedIn.value = true
  const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
  await router.replace(redirect)
}
</script>

<template>
  <button type="button" @click="signIn">Sign in</button>
</template>
```

`router.replace` rather than `router.push`, so the login page does not stay in the history and the back button does not return to it. The `typeof` check matters because a query value can also be an array or `null`, and a redirect to `null` is not a page.

## Avoid the redirect loop

A common mistake with `beforeEach` is to send every signed-out visitor to the login page, wherever they are going:

*src/router/index.ts**Breaks***

```
router.beforeEach((to) => {
  if (!isSignedIn.value) {
    return { name: 'login' }
  }
})
```

A signed-out person opens `/login`. The guard runs, sees they are signed out, and redirects to `/login`. That is a new navigation, so the guard runs again, and again. In development Vue Router notices and prints `Detected a possibly infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow.` In production the page just hangs.

The fix is the guard you already wrote. It redirects only when the destination needs it, which is what `meta.requiresAuth` was for:

*src/router/index.ts**Works***

```
router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isSignedIn.value) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})
```

The rule generalises: a guard must have at least one path through it that returns nothing, and the route it redirects to must take that path. If you also want to keep signed-in users away from `/login`, add a second condition, `if (to.name === 'login' && isSignedIn.value) return { name: 'account' }`, and check that `/account` does not bounce them back.

## Check a role on one route with beforeEnter

Some checks belong to one route only. An admin page that needs a role is the classic case. Put it on the record with `beforeEnter`, and it never runs for any other route:

*src/router/index.ts*

```
import type { RouteRecordRaw } from 'vue-router'
import { isAdmin } from '../auth'

const adminRoute: RouteRecordRaw = {
  path: '/admin',
  name: 'admin',
  component: () => import('../pages/AdminPage.vue'),
  meta: { requiresAuth: true, title: 'Admin' },
  beforeEnter: () => {
    if (!isAdmin.value) return { name: 'home' }
  },
}
```

Declare it in `src/router/index.ts` above `createRouter`, and add `adminRoute` to the `routes` array.

Order matters here: `beforeEach` runs first, so by the time `beforeEnter` runs the sign-in check has already passed and this guard only has to think about the role. `beforeEnter` runs when entering the route from a different route. It does not run again when only the params, query or hash change.

## Warn before unsaved changes are lost

The guard that stops a person losing work lives in the component, because only the component knows whether the form is dirty:

*src/pages/AccountPage.vue*

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

const dirty = ref(false)

onBeforeRouteLeave(() => {
  if (!dirty.value) return
  const leave = window.confirm('You have unsaved changes. Leave anyway?')
  if (!leave) return false
})
</script>

<template>
  <textarea @input="dirty = true" />
</template>
```

`onBeforeRouteLeave` is called in `<script setup>` like a lifecycle hook, and it registers a guard for the route this component renders. Returning `false` keeps the person on the page. Set `dirty` back to `false` after a successful save.

This covers navigation inside the app only. Closing or reloading the tab fires the browser's `beforeunload` event instead, which you handle separately.

## Set the page title with afterEach

`afterEach` runs when a navigation has finished, including one that a guard stopped. It cannot change the outcome, which makes it the right place for side effects: analytics, focus management, and the document title.

*src/router/index.ts*

```
router.afterEach((to, _from, failure) => {
  if (failure) return
  document.title = to.meta.title ? `${to.meta.title} · My app` : 'My app'
})
```

Each route record that wants a title sets `meta.title`, as the account and admin routes above do. The underscore in `_from` marks an argument the hook does not read. The `vue-ts` template from `npm create vite` turns on TypeScript's `noUnusedParameters`, so a plain `from` would fail its build.

The third argument, `failure`, is set when the navigation did not happen: a guard returned `false`, a newer navigation replaced it, or the visitor was already on that page. In the first two cases, `to` is a page the visitor never reached. Without the early return, choosing Cancel in the unsaved-changes prompt would rename the tab after the page they decided not to open. A redirect is not a failure: the hook runs once, for the page the visitor lands on.

## Guards are not security

A guard runs in the browser, in code the visitor has downloaded. Anyone can open the devtools, set `isSignedIn.value = true`, and walk straight into `/account`. They can also fetch the lazy-loaded chunk for the page directly, since Vite publishes it as a static file.

That is fine, because the page itself holds nothing. What matters is the data. Every API request the account page makes must carry a token or a cookie, and the server must check it and answer `401` when it is missing.

The guard's job is to make the experience good: no flash of an empty page, a clear path to sign in, and a return trip to where you were. Security is the server's job and only the server's.

**Check it stuck**3 questions from the Navigation guards quiz.

[**Practice now**](https://vuejs.school/learn/routing/navigation-guards?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Dynamic routes, params and typed routes**](https://vuejs.school/community/blog/dynamic-routes-params-and-typed-routes) [Tutorial · 5 min read**Migrating from unplugin-vue-router to Vue Router 5**](https://vuejs.school/community/blog/migrating-from-unplugin-vue-router-to-vue-router-5)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-06","description":"Vue Router guards that send visitors to login and back, warn about unsaved changes and set page titles, without redirect loops or a false sense of security.","name":"Navigation guards: protecting routes the right way","url":"https://vuejs.school/community/blog/navigation-guards-protecting-routes","breadcrumb":{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/navigation-guards-protecting-routes"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-06","description":"Vue Router guards that send visitors to login and back, warn about unsaved changes and set page titles, without redirect loops or a false sense of security.","headline":"Navigation guards: protecting routes the right way","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXk%2Cdescription_~VnVlIFJvdXRlciBndWFyZHMgdGhhdCBzZW5kIHZpc2l0b3JzIHRvIGxvZ2luIGFuZCBiYWNrLCB3YXJuIGFib3V0IHVuc2F2ZWQgY2hhbmdlcyBhbmQgc2V0IHBhZ2UgdGl0bGVzLCB3aXRob3V0IHJlZGlyZWN0IGxvb3BzIG9yIGEgZmFsc2Ugc2Vuc2Ugb2Ygc2VjdXJpdHku%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9uYXZpZ2F0aW9uLWd1YXJkcy1wcm90ZWN0aW5nLXJvdXRlcyI%2Cs_2RKgGWDELLXnPHpZ.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/navigation-guards-protecting-routes#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Navigation guards: protecting routes the right way","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXk%2Cdescription_~VnVlIFJvdXRlciBndWFyZHMgdGhhdCBzZW5kIHZpc2l0b3JzIHRvIGxvZ2luIGFuZCBiYWNrLCB3YXJuIGFib3V0IHVuc2F2ZWQgY2hhbmdlcyBhbmQgc2V0IHBhZ2UgdGl0bGVzLCB3aXRob3V0IHJlZGlyZWN0IGxvb3BzIG9yIGEgZmFsc2Ugc2Vuc2Ugb2Ygc2VjdXJpdHku%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9uYXZpZ2F0aW9uLWd1YXJkcy1wcm90ZWN0aW5nLXJvdXRlcyI%2Cs_2RKgGWDELLXnPHpZ.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~TmF2aWdhdGlvbiBndWFyZHM6IHByb3RlY3Rpbmcgcm91dGVzIHRoZSByaWdodCB3YXk%2Cdescription_~VnVlIFJvdXRlciBndWFyZHMgdGhhdCBzZW5kIHZpc2l0b3JzIHRvIGxvZ2luIGFuZCBiYWNrLCB3YXJuIGFib3V0IHVuc2F2ZWQgY2hhbmdlcyBhbmQgc2V0IHBhZ2UgdGl0bGVzLCB3aXRob3V0IHJlZGlyZWN0IGxvb3BzIG9yIGEgZmFsc2Ugc2Vuc2Ugb2Ygc2VjdXJpdHku%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9uYXZpZ2F0aW9uLWd1YXJkcy1wcm90ZWN0aW5nLXJvdXRlcyI%2Cs_2RKgGWDELLXnPHpZ.png"}]}
```