You have an /account page and a /login page. Someone who is not signed in opens /account. You want them sent to /login, and after they sign in you want them back at /account, not dumped on the homepage. You also want the browser tab to show the page's name, and you want a half-finished form to ask before it is thrown away.
Navigation guards do all of this. You will build each one with Vue Router 4.5 in a Vite app, avoid the redirect loop, and see why the server still has to check every request.
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,beforeResolveandafterEach. - On a route record:
beforeEnter. - Inside a page component:
onBeforeRouteLeaveandonBeforeRouteUpdate.
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:
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:
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.
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.
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:
<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:
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:
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:
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:
<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.
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.