Navigation guards
Check routes before they open with a global beforeEach guard, typed route meta and a Pinia store, stop people losing unsaved work with onBeforeRouteLeave(), and remember that only the server can protect data.
What you'll learn
When you finish, you can do these.- 1Send signed-out visitors to sign in with beforeEach
- 2Bring people back to the page they wanted after sign-in
- 3Stop people losing unsaved work with onBeforeRouteLeave()
- 4Spot and fix a store used too early and a sign-in loop
Picture a small app with four pages: Home, Account, Settings and Draft. Account lists the signed-in person's orders, so a signed-out visitor must sign in first, then go on to Account. On Draft you type a note, and a click on Home should not throw it away. Navigation guards handle both. They are functions that Vue Router, Vue's library for pages, runs between one page and the next, and each can let the move through, cancel it or send it elsewhere.
The Code tab holds that app: a guard in router.ts checks every move, and Draft guards itself. By the end you protect Settings too. You need one idea from Dynamic routes and params, lesson two of this track: the query string, the part of an address after ?, as in /?q=be, where that lesson's user list keeps its search.
Check every navigation with beforeEach
router.beforeEach() registers a guard that runs before every navigation. It receives to, the route you are going to. Return nothing to let the navigation go ahead, false to cancel it and stay put, or a route location to go there instead, such as { name: 'login' }, the route named login.
Mark the routes that need a check with meta, a free-form object on the route. In router.ts, the account route has meta: { requiresAuth: true }, and the guard reads it as to.meta.requiresAuth. A child route, one nested inside another (Nested routes and layouts), also gets its parents' meta, so one flag on a parent protects every child.
Who is signed in lives in stores/auth.ts, a Pinia store: state that every component and the router can share. useAuthStore() returns it, and its user is null while nobody is signed in.
Here is the guard in router.ts, under a declare module block that types the flag:
declare module 'vue-router' {
interface RouteMeta { requiresAuth?: boolean }
}
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.user) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
Click Account while signed out: the sign-in page opens instead, and the address line under the menu reads /login?redirect=/account. The guard calls useAuthStore() inside itself for a reason shown under What goes wrong.
The declare module block adds requiresAuth to RouteMeta, Vue Router's type for meta, so your editor knows the flag is a boolean: meta: { requiresAuth: 'yes' } fails with Type 'string' is not assignable to type 'boolean | undefined'. A misspelled flag, such as requireAuth, still passes: meta takes any key.
The guard's return puts the asked-for address, to.fullPath, in the query string, the part after ?, as redirect. The sign-in page, pages/Login.vue, reads it back as route.query.redirect, where route is the current route from useRoute().
Anyone can send a link with their own redirect, though, so the page's target follows only a path inside the app: one that starts with a single / (two slashes lead to another site).
const target = computed(() => {
const r = route.query.redirect
return typeof r === 'string' && r.startsWith('/') && !r.startsWith('//') ? r : '/'
})
Click Sign in as Asha: the page calls router.push(target.value), the router's way to change page from code, and Account opens.
Guards for one route and one page
Guards can also sit on one route or inside one page, which is how Draft keeps an unsaved note. Several kinds can run during one navigation, always in this order.
- Click or push
- Leave guardsonBeforeRouteLeave
- beforeEach
- beforeEnteron the route
- Confirmedaddress and page change
- afterEach
beforeEnter sits on one route in the routes list and runs only when you enter that route. router.afterEach() runs once the page has changed, too late to stop anything, which suits page titles and analytics.
onBeforeRouteLeave() runs inside the page you are leaving, where the unsaved state lives. In pages/Draft.vue, text holds the note and blocked switches a message on. The guard returns false, which cancels the navigation, while the note has text:
onBeforeRouteLeave(() => {
if (text.value) {
blocked.value = true
return false
}
})
Type a note and click Home: you stay on Draft, which says "You have an unsaved note." next to a Discard it button.
The guard only sees navigations inside the app. A reload, a closed tab or a typed address never reaches the router, so Draft also listens to the browser's own beforeunload event with onUnload:
function onUnload(event: BeforeUnloadEvent) {
if (text.value) event.preventDefault()
}
While the note has text, preventDefault() makes the browser ask before the tab closes or reloads.
What goes wrong
Two mistakes break the sign-in guard. The first is calling the store at the top of router.ts, outside the guard.
const auth = useAuthStore() // runs when main.ts imports this file
router.beforeEach((to) => {
if (to.meta.requiresAuth && !auth.user) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
main.ts, the file that starts the app, imports router.ts before it reaches use(createPinia()), the call that installs Pinia, so the store call runs before Pinia exists. The page stays blank, and in development Pinia throws "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"? The preview does not show this error after an edit, so read it here. Inside the guard, the call runs during a navigation, when Pinia is installed.
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.user) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
The second mistake is protecting the sign-in page itself, which makes a loop.
{ path: '/login', name: 'login', component: Login, meta: { requiresAuth: true } },
Each redirect to /login is a new navigation, so the guard runs again and redirects again. This guard adds the address to the query, so every redirect has a new address, and the tab freezes, in the preview too.
When a guard returns the same location each time, such as { name: 'login' } with no query, Vue Router in development gives up and warns Detected a possibly infinite redirection in a navigation guard when going from "/" to "/login". Try it 3 shows it. Production builds have no such check, so fix the routes, not the symptom:
{ path: '/login', name: 'login', component: Login },
With the sign-in page open to everyone, the loop is gone.
What to remember
router.beforeEach()runs before every navigation and reads flags fromto.meta, typed throughRouteMeta. Return nothing to go ahead,falseto cancel, or a route location to redirect.- Put
to.fullPathin the query asredirect, and after sign-in follow it only when it is a path inside the app. onBeforeRouteLeave()protects unsaved work inside the app. Reloads and closed tabs needbeforeunload.- Call stores inside the guard, never at the top of
router.ts, and never mark the sign-in page with the flag your guard checks. - A guard only decides what the browser shows. The server must check every request.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Protect Settings. In router.ts, add
meta: { requiresAuth: true }to the settings route. While signed out, click Settings: the Sign in page says Sign in to open /settings. Click Sign in as Asha and Settings opens. - Sign in, open Account, then click Sign out. You are still on Account, and it says Orders for no one, because guards only run when you navigate. In App.vue, import
useRouteanduseRouterfromvue-router, addconst route = useRoute()andconst router = useRouter(), and putif (route.meta.requiresAuth) router.push({ name: 'home' })afterauth.signOut(). Sign out on Account again: you land on Home. Sign out on Draft and you stay there. - Make the loop the safe way, in this order. First change the guard's return to
return { name: 'login' }, without the query. Then addmeta: { requiresAuth: true }to the login route. Clicking Account while signed out does nothing, and the preview shows[VUE_ROUTER_R0009]with the warning from What goes wrong. Remove the meta from the login route and put the query back. Never try it with the query in place: the tab freezes.