- Vue.js
Vue Routerナビゲーションガード|beforeEachで認証リダイレクト
Vue Routerのナビゲーションガードとは、ページを移動する直前に関数を挟み、「通す・止める・別のページへ送る」を決められる仕組みです。 ログインしていない人をログイン画面へ送る処理は、全ルート共通のガードである beforeEach に書くのが基本形です。
この記事では、ガードの種類と実行順、戻り値ごとの挙動、meta.requiresAuth と Pinia を組み合わせた認証リダイレクト一式を、すべて実際に動かした出力つきで紹介します。ログイン後に元のページへ戻す redirect クエリ、無限リダイレクトの再現と直し方、RouteMeta の型拡張、next() を2回呼んだときの警告文まで、コピーして動かせる形でまとめました。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / vue 3.5.43 / vue-router 5.3.1 / pinia 4.0.3 / typescript 7.0.2 / jsdom 30.1.1。ブラウザの代わりに jsdom と createMemoryHistory を使い、Node.js 上で実行しています。
ナビゲーションガードの早見表
| ガード | 書く場所 | 呼ばれるタイミング | 向いている用途 |
|---|---|---|---|
router.beforeEach |
ルーター全体 | すべての移動の最初のほう | ログイン確認・権限確認 |
beforeEnter |
ルート定義 | そのルートに入るときだけ | 特定ページだけの条件 |
beforeRouteEnter |
コンポーネント | コンポーネントが作られる前 | 表示前のデータ確認 |
beforeRouteUpdate / onBeforeRouteUpdate |
コンポーネント | 同じコンポーネントのままパラメータが変わるとき | /users/1 から /users/2 への再読み込み |
beforeRouteLeave / onBeforeRouteLeave |
コンポーネント | そのページを離れるとき | 入力途中の離脱確認 |
router.beforeResolve |
ルーター全体 | 移動が確定する直前 | 全ガードと非同期コンポーネントの読み込み後に行う処理 |
router.afterEach |
ルーター全体 | 移動が確定した後 | ページタイトルの変更・アクセス記録(移動は止められない) |
ガードの戻り値で移動の行き先が決まります。
| 戻り値 | 挙動 |
|---|---|
何も返さない(undefined)または true |
そのまま通す |
false |
移動を中止し、元のページにとどまる |
パス文字列 '/login' またはオブジェクト { name: 'login', query: {...} } |
その場所へリダイレクトする |
throw new Error(...) |
移動を中止し、router.onError に渡される |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue Routerナビゲーションガード|beforeEachで認証リダイレクト」
動かすための準備
この記事のコードは、次の手順でそのまま動かせます。作業用のフォルダを作り、パッケージを入れます。
npm init -y
npm i vue vue-router pinia jsdom
コンポーネントを画面に出す例では DOM が必要になるため、jsdom をグローバルに登録する小さなファイルを用意します。
// setup.mjs
import { JSDOM } from 'jsdom'
const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: 'http://localhost/' })
for (const k of ['window','document','navigator','Node','Element','HTMLElement','SVGElement','MathMLElement','Event','history','location']) {
try { globalThis[k] = dom.window[k] } catch (e) { Object.defineProperty(globalThis, k, { value: dom.window[k], configurable: true }) }
}
記事中のルーターは、URL をメモリー上で管理する createMemoryHistory で作っています。ブラウザで動かすアプリでは、ここを createWebHistory に置き換えます。ガードの書き方は同じです。
ガードの実行順を console.log で確かめる
グローバル・ルート単位・コンポーネント内の全ガードにログを入れて、3つの移動パターンを順に実行します。
// order.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createRouter, createMemoryHistory, RouterView, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
const Home = {
render: () => h('h1', 'Home'),
beforeRouteLeave(to, from) { console.log(' [Home] beforeRouteLeave') },
}
const User = {
render() { return h('h1', 'User ' + this.$route.params.id) },
beforeRouteEnter(to, from) { console.log(' [User] beforeRouteEnter') },
beforeRouteUpdate(to, from) { console.log(' [User] beforeRouteUpdate') },
beforeRouteLeave(to, from) { console.log(' [User] beforeRouteLeave') },
mounted() { console.log(' [User] mounted') },
}
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Home },
{
path: '/users/:id',
component: () => { console.log(' (非同期コンポーネントを読み込み)'); return Promise.resolve(User) },
beforeEnter(to, from) { console.log(' [route] beforeEnter') },
},
],
})
router.beforeEach((to, from) => { console.log(' [global] beforeEach') })
router.beforeResolve((to, from) => { console.log(' [global] beforeResolve') })
router.afterEach((to, from, failure) => { console.log(' [global] afterEach') })
const app = createApp({ render: () => h(RouterView) })
app.use(router)
const log = console.log; console.log = () => {}
await router.push('/'); await router.isReady(); app.mount('#app')
console.log = log
console.log('--- / → /users/1'); await router.push('/users/1')
console.log(' DOM:', document.body.textContent)
console.log('--- /users/1 → /users/2(同じコンポーネントを再利用)'); await router.push('/users/2')
console.log(' DOM:', document.body.textContent)
console.log('--- /users/2 → /'); await router.push('/')
console.log(' DOM:', document.body.textContent)
node order.mjs
実行結果:
--- / → /users/1
[global] beforeEach
[route] beforeEnter
(非同期コンポーネントを読み込み)
[User] beforeRouteEnter
[global] beforeResolve
[global] afterEach
[User] mounted
DOM: User 1
--- /users/1 → /users/2(同じコンポーネントを再利用)
[global] beforeEach
[User] beforeRouteUpdate
[global] beforeResolve
[global] afterEach
DOM: User 2
--- /users/2 → /
[User] beforeRouteLeave
[global] beforeEach
[global] beforeResolve
[global] afterEach
DOM: Home
この出力から、次のことがわかります。
- 新しいページに入るときは、beforeEach → beforeEnter → 非同期コンポーネントの読み込み → beforeRouteEnter → beforeResolve → afterEach の順で、コンポーネントの mounted は afterEach の後です。
- 同じコンポーネントのままパラメータだけが変わるとき(
/users/1→/users/2)は、beforeEnter と beforeRouteEnter は呼ばれず、代わりに beforeRouteUpdate が呼ばれます。 - ページを離れるときは、beforeRouteLeave が beforeEach より先に呼ばれます。
公式ドキュメント(Navigation Guards の「The Full Navigation Resolution Flow」)には、全体の流れが12段階で載っています。上のログと照らし合わせると次のとおりです。
| 順番 | 公式ドキュメントの記載 | 上のログで対応する行 |
|---|---|---|
| 1 | ナビゲーションが始まる | router.push(...) |
| 2 | 離れるコンポーネントの beforeRouteLeave | [User] beforeRouteLeave |
| 3 | グローバルの beforeEach | [global] beforeEach |
| 4 | 再利用されるコンポーネントの beforeRouteUpdate | [User] beforeRouteUpdate |
| 5 | ルート定義の beforeEnter | [route] beforeEnter |
| 6 | 非同期ルートコンポーネントの解決 | (非同期コンポーネントを読み込み) |
| 7 | 入るコンポーネントの beforeRouteEnter | [User] beforeRouteEnter |
| 8 | グローバルの beforeResolve | [global] beforeResolve |
| 9 | ナビゲーションが確定する | (URL が切り替わる) |
| 10 | グローバルの afterEach | [global] afterEach |
| 11 | DOM の更新 | [User] mounted |
| 12 | beforeRouteEnter の next に渡したコールバック | (この例では使っていません) |
認証の確認を beforeEach に置くと、ルート単位やコンポーネント内のガード、非同期コンポーネントの読み込みより前に判定できます。 逆に「すべてのガードを通過し、コンポーネントの読み込みも終わった後で実行したい処理」は beforeResolve が合います。
beforeRouteEnter では this を使えない
公式ドキュメントには、beforeRouteEnter はナビゲーションが確定する前に呼ばれ、その時点ではコンポーネントがまだ作られていないため this にアクセスできない、と書かれています。インスタンスが必要なときは next にコールバックを渡すと、確定後にインスタンスを引数にして呼ばれます(上の表の12番目)。beforeRouteUpdate と beforeRouteLeave は、すでにあるコンポーネントに対して呼ばれるガードです。
戻り値ごとの挙動を確かめる
同じ /a への移動に対して、beforeEach の戻り値だけを切り替えて結果を比べます。router.push が返す値と、移動後の URL も一緒に表示します。
// ret.mjs
import { createRouter, createMemoryHistory, isNavigationFailure, NavigationFailureType } from 'vue-router'
const C = { render: () => null }
const router = createRouter({
history: createMemoryHistory(),
routes: ['/', '/a', '/b', '/login'].map(path => ({ path, component: C })).concat([{ path: '/named', name: 'named', component: C }]),
})
let mode
router.beforeEach((to, from) => {
if (to.path !== '/a') return
switch (mode) {
case 'undefined': return
case 'true': return true
case 'false': return false
case 'string': return '/login'
case 'object': return { path: '/login', query: { redirect: to.fullPath } }
case 'name': return { name: 'named', replace: true }
case 'throw': throw new Error('ユーザー情報を取得できませんでした')
}
})
router.afterEach((to, from, failure) => {
if (to.path === '/' || to.path === '/b') return // 準備用の移動は表示しない
console.log(' afterEach: to=' + to.fullPath + (failure ? ' failure=' + failure.message : ''))
})
router.onError((err) => console.log(' onError:', err.message))
await router.push('/'); await router.isReady()
for (mode of ['undefined', 'true', 'false', 'string', 'object', 'name', 'throw']) {
await router.push('/b')
console.log('■ 戻り値 = ' + mode)
let result
try { result = await router.push('/a') } catch (e) { result = 'reject: ' + e.message }
console.log(' push の結果:', result === undefined ? 'undefined(成功)' : (isNavigationFailure(result, NavigationFailureType.aborted) ? 'NavigationFailure(aborted)' : String(result)))
console.log(' 現在のURL:', router.currentRoute.value.fullPath)
}
実行結果:
■ 戻り値 = undefined
afterEach: to=/a
push の結果: undefined(成功)
現在のURL: /a
■ 戻り値 = true
afterEach: to=/a
push の結果: undefined(成功)
現在のURL: /a
■ 戻り値 = false
afterEach: to=/a failure=Navigation aborted from "/b" to "/a" via a navigation guard.
push の結果: NavigationFailure(aborted)
現在のURL: /b
■ 戻り値 = string
afterEach: to=/login
push の結果: undefined(成功)
現在のURL: /login
■ 戻り値 = object
afterEach: to=/login?redirect=/a
push の結果: undefined(成功)
現在のURL: /login?redirect=/a
■ 戻り値 = name
afterEach: to=/named
push の結果: undefined(成功)
現在のURL: /named
■ 戻り値 = throw
onError: ユーザー情報を取得できませんでした
push の結果: reject: ユーザー情報を取得できませんでした
現在のURL: /b
結果を表にまとめます。
| ガードの戻り値 | 移動後のURL | await router.push() の結果 |
afterEach |
|---|---|---|---|
undefined(return なし) |
/a |
undefined |
呼ばれる |
true |
/a |
undefined |
呼ばれる |
false |
/b のまま |
NavigationFailure(aborted) | 第3引数に failure が入って呼ばれる |
'/login' |
/login |
undefined |
リダイレクト先で呼ばれる |
{ path: '/login', query: {...} } |
/login?redirect=/a |
undefined |
リダイレクト先で呼ばれる |
{ name: 'named', replace: true } |
/named |
undefined |
リダイレクト先で呼ばれる |
throw new Error(...) |
/b のまま |
Promise が reject される | 呼ばれず、onError が呼ばれる |
押さえておきたい点は3つです。
falseを返しても例外にはなりません。router.pushは NavigationFailure というオブジェクトを返します。中止されたかどうかはisNavigationFailure(result, NavigationFailureType.aborted)で判定できます。- afterEach は中止されたときも呼ばれます。 第3引数 failure の有無で、確定した移動かどうかを見分けられます。アクセス記録を取るときは failure が無い場合だけ記録すると、表示されたページと記録が一致します。
- ガードの中で例外を投げると、
router.pushの Promise が reject されます。router.onErrorを登録しておくと、1か所でまとめて受け取れます。
meta.requiresAuth と Pinia で認証リダイレクトを作る
ここからが本題です。次の4つの動きを1つのファイルにまとめます。
- ログインが必要なページには
meta: { requiresAuth: true }を付ける - 未ログインで開いたら、ログイン画面へ送る。そのとき開こうとしたURLを redirect クエリに入れておく
- ログインに成功したら、redirect クエリのURLへ戻す
- ログイン済みの人がログイン画面を開いたら、ホームへ送る
// auth.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createPinia, defineStore } from 'pinia'
import { createRouter, createMemoryHistory, RouterView } from 'vue-router'
// ---- stores/auth.js に相当 ----
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null }),
getters: { isLoggedIn: (state) => state.user !== null },
actions: {
async login(name, password) {
await new Promise((r) => setTimeout(r, 50)) // APIの代わり
this.user = { name }
},
logout() { this.user = null },
},
})
// ---- router/index.js に相当 ----
const page = (name) => ({ render: () => h('h1', name) })
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: page('Home') },
{ path: '/login', name: 'login', component: page('Login'), meta: { guestOnly: true } },
{
path: '/mypage', component: { render: () => h(RouterView) }, meta: { requiresAuth: true },
children: [
{ path: '', component: page('MyPage') },
{ path: 'orders/:id', component: page('OrderDetail') }, // 親の meta を引き継ぐ
],
},
],
})
router.beforeEach((to, from) => {
const auth = useAuthStore() // ガードの中で呼ぶ
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.guestOnly && auth.isLoggedIn) {
return '/'
}
})
// ログイン後に戻る先を決める(アプリ内のパスだけを通す)
function safeRedirect(value) {
const path = Array.isArray(value) ? value[0] : value
if (typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')) return path
return '/'
}
// ---- main.js に相当 ----
const app = createApp({ render: () => h(RouterView) })
app.use(createPinia())
app.use(router)
await router.push('/'); await router.isReady()
app.mount('#app')
const show = (label) => console.log(label.padEnd(34), '→', router.currentRoute.value.fullPath, '|', document.body.textContent)
show('1. 最初の表示')
await router.push('/mypage/orders/42?tab=history')
show('2. 未ログインで /mypage/orders/42')
console.log(' to.meta の中身(子ルート):', JSON.stringify(router.resolve('/mypage/orders/42').meta))
// Login.vue の送信処理に相当
const auth = useAuthStore()
await auth.login('taro', 'secret')
await router.replace(safeRedirect(router.currentRoute.value.query.redirect))
show('3. ログイン後')
await router.push('/login')
show('4. ログイン済みで /login')
auth.logout()
await router.push('/mypage')
show('5. ログアウト後に /mypage')
for (const v of ['/mypage', 'https://example.com/', '//example.com', undefined, ['/a', '/b']]) {
console.log(' safeRedirect(' + JSON.stringify(v) + ') =', safeRedirect(v))
}
実行結果:
1. 最初の表示 → / | Home
2. 未ログインで /mypage/orders/42 → /login?redirect=/mypage/orders/42?tab=history | Login
to.meta の中身(子ルート): {"requiresAuth":true}
3. ログイン後 → /mypage/orders/42?tab=history | OrderDetail
4. ログイン済みで /login → / | Home
5. ログアウト後に /mypage → /login?redirect=/mypage | Login
safeRedirect("/mypage") = /mypage
safeRedirect("https://example.com/") = /
safeRedirect("//example.com") = /
safeRedirect(undefined) = /
safeRedirect(["/a","/b"]) = /a
未ログインで /mypage/orders/42?tab=history を開くと /login?redirect=/mypage/orders/42?tab=history へ送られ、ログイン後はクエリ(?tab=history)まで含めて元のページに戻っています。
ポイント1:to.fullPath を redirect に入れる
to.path ではなく to.fullPath を使うと、クエリ文字列やハッシュも含めて戻せます。 一覧ページの絞り込み条件や、タブの選択状態をURLで持っているページでも、そのまま復元できます。
ポイント2:meta は親から子へ引き継がれる
出力の2番目で、子ルート /mypage/orders/42 の meta が {"requiresAuth":true} になっています。子ルートには meta を書いていませんが、親の /mypage に付けた値が入っています。公式ドキュメント(Route Meta Fields)には、route.meta は親から子までのすべての meta を非再帰的にマージしたもの、と書かれています。ログインが必要な領域は、親ルートに1回書くだけで配下すべてに効きます。 to.matched.some(record => record.meta.requiresAuth) と書かなくても、to.meta.requiresAuth で判定できます。
ポイント3:ストアはガードの中で取得する
useAuthStore() は、beforeEach の関数の中で呼んでいます。ルーターのファイルの先頭(モジュールの直下)で呼ぶと、app.use(createPinia()) より前に実行されることになり、次のメッセージが出ます。
[🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?
See https://pinia.vuejs.org/core-concepts/outside-component-usage.html for help.
This will fail in production.
ガードの中で呼べば、実行されるのは移動のたび(Pinia の登録後)なので、この問題は起きません。 main.js では app.use(createPinia()) を app.use(router) より先に書いておくと、最初の移動のときにもストアが使えます。
ポイント4:redirect の値はアプリ内のパスだけ通す
redirect クエリはURLの一部なので、誰でも好きな値を入れられます。safeRedirect は「/ で始まり、// では始まらない文字列」だけを通し、それ以外はホームへ送る関数です。出力の最後の5行が、その動きです。https://example.com/ や //example.com はホームに置き換わっています。同じクエリが2つ付いた場合は配列になるため、先頭だけを使っています。
ブラウザ向けのファイルに分けるときの対応
上のコードは1ファイルで動かすためにまとめています。アプリのフォルダ構成に置くときは、ストアを src/stores/auth.js、ルーターとガードを src/router/index.js(history は createWebHistory)に分け、src/main.js で app.use(createPinia()) → app.use(router) の順に登録します。ログイン画面のコンポーネントでは、auth.login() の後に router.replace(safeRedirect(route.query.redirect)) を呼びます。
ログイン後の移動に push ではなく replace を使うと、履歴にログイン画面が残りません。ブラウザの「戻る」でログイン画面に戻ってしまう動きを避けられます。
なお、ガードは画面の表示を切り替える仕組みです。データを返すAPI側でも、ログイン状態と権限の確認を行う構成にしておくと、画面とデータの両方で守れます。
無限リダイレクトを再現して直す
認証ガードで最初に出会いやすいのが、無限リダイレクトです。「未ログインなら必ずログイン画面へ」という条件を、ログイン画面そのものにも適用すると起きます。
// loop.mjs
import { createRouter, createMemoryHistory } from 'vue-router'
const C = { render: () => null }
const make = () => createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: C }, { path: '/login', name: 'login', component: C }, { path: '/mypage', component: C, meta: { requiresAuth: true } }] })
const isLoggedIn = false
console.log('=== 直す前:/login も含めて毎回 /login へ返す ===')
{
const router = make()
let count = 0
router.beforeEach((to) => {
count++
if (!isLoggedIn) return { name: 'login' }
})
router.onError((e) => console.log('onError:', e.message))
try { await router.push('/mypage') } catch (e) { console.log('reject:', e.message) }
console.log('beforeEach が呼ばれた回数:', count, '/ 現在のURL:', router.currentRoute.value.fullPath)
}
console.log('=== 直した後:行き先が login のときは通す ===')
{
const router = make()
let count = 0
router.beforeEach((to) => {
count++
if (!isLoggedIn && to.name !== 'login') return { name: 'login' }
})
await router.push('/mypage')
console.log('beforeEach が呼ばれた回数:', count, '/ 現在のURL:', router.currentRoute.value.fullPath)
}
実行結果:
=== 直す前:/login も含めて毎回 /login へ返す ===
[VUE_ROUTER_R0009] Detected a possibly infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow. This might break in production if not fixed.
├▶ fix: A guard is returning a new location on every call; make that return conditional so it only redirects when actually needed.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Global-Before-Guards
reject: Infinite redirect in navigation guard
beforeEach が呼ばれた回数: 32 / 現在のURL: /
=== 直した後:行き先が login のときは通す ===
beforeEach が呼ばれた回数: 2 / 現在のURL: /login
直す前は、beforeEach が32回呼ばれたところで Vue Router が検知し、Infinite redirect in navigation guard というエラーで移動を止めています。警告文の fix の行にも「毎回新しい場所を返している。必要なときだけリダイレクトするよう条件を付ける」という趣旨が書かれています。
直し方は「リダイレクト先のルートは、その条件から外す」ことです。 代表的な方法は2つです。
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| 行き先の名前で外す | if (!isLoggedIn && to.name !== 'login') |
全ページをログイン必須にするアプリ |
| meta で対象を絞る | if (to.meta.requiresAuth && !isLoggedIn) |
公開ページとログイン必須ページが混ざるアプリ |
前の章の auth.mjs は2番目の方法です。ログイン画面には requiresAuth が付いていないので、条件に入りません。「ログイン済みなら /login からホームへ」の条件を足すときも、送り先のホームが guestOnly でないことを確認しておくと、同じ形のループを避けられます。
next() を2回呼んだときの警告と、return への書き換え
以前のバージョンの Vue Router では、第3引数の next を呼んで行き先を決める書き方がありました。公式ドキュメントでは、next は引き続き使えるものの、1回のガードの中で必ず1回だけ呼ぶ必要がある、と説明されています。よくある書き方で試します。
// next2.mjs
import { createRouter, createMemoryHistory } from 'vue-router'
const C = { render: () => null }
const mk = () => createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: C }, { path: '/login', component: C }, { path: '/mypage', component: C, meta: { requiresAuth: true } }] })
const isLoggedIn = false
console.log('=== A: next を2回呼ぶ ===')
{ const router = mk()
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isLoggedIn) next('/login')
next()
})
await router.push('/mypage'); console.log('現在のURL:', router.currentRoute.value.fullPath) }
console.log('=== B: next を受け取るが呼ばない ===')
{ const router = mk()
router.beforeEach((to, from, next) => { if (to.path === '/mypage') { /* next() を忘れた */ } else next() })
const r = await Promise.race([router.push('/mypage').then(() => '完了'), new Promise(r => setTimeout(() => r('200ms待っても完了しない'), 200))])
console.log('push:', r, '/ 現在のURL:', router.currentRoute.value.fullPath) }
console.log('=== C: return で書く ===')
{ const router = mk()
router.beforeEach((to) => { if (to.meta.requiresAuth && !isLoggedIn) return '/login' })
await router.push('/mypage'); console.log('現在のURL:', router.currentRoute.value.fullPath) }
実行結果:
=== A: next を2回呼ぶ ===
[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
[VUE_ROUTER_R0024] The "next" callback was called more than once in one navigation guard when going from "/" to "/mypage". 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
[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
現在のURL: /login
=== B: next を受け取るが呼ばない ===
push: 200ms待っても完了しない / 現在のURL: /
=== C: return で書く ===
現在のURL: /login
A の例は、next('/login') の後に return が無いため、続けて next() も実行されます。vue-router 5.3.1 では2種類の警告が出ました。
| 警告コード | 内容 |
|---|---|
VUE_ROUTER_R0025 |
next コールバックは非推奨。値を return する形に書き換える |
VUE_ROUTER_R0024 |
1つのガードの中で next が2回以上呼ばれた。本番ビルドではうまく動かなくなる |
B の例は、next を引数に書いたのに呼ばない分岐がある場合です。移動が完了せず、URL も変わりません。エラーも出ないので、原因を探すのに時間がかかりやすい形です。
C のように return で書けば、どちらも起きません。 書き換えは機械的にできます。
| next を使う書き方 | return を使う書き方 |
|---|---|
next() |
return(何も返さない) |
next(false) |
return false |
next('/login') |
return '/login' |
next({ name: 'login' }) |
return { name: 'login' } |
非同期のガード:ログイン状態を問い合わせてから判定する
ページを再読み込みすると、Pinia のストアは初期状態に戻ります。Cookie でセッションを持つアプリでは、最初の移動のときに「いまログインしているか」をサーバーへ問い合わせてから判定する形になります。ガードは async 関数にでき、await している間、移動は待たされます。
次の例では、問い合わせに300ミリ秒かかる想定にして、経過時間をログに出しています。あわせて、入力途中の離脱を止める onBeforeRouteLeave も入れました。
// async.mjs
import './setup.mjs'
import { createApp, h } from 'vue'
import { createPinia, defineStore } from 'pinia'
import { createRouter, createMemoryHistory, RouterView, onBeforeRouteLeave } from 'vue-router'
const t0 = Date.now()
const log = (...a) => console.log(String(Date.now() - t0).padStart(4) + 'ms', ...a)
const useAuthStore = defineStore('auth', {
state: () => ({ user: null, checked: false }),
actions: {
// 再読み込み直後に1回だけ、サーバーへログイン状態を問い合わせる想定
async fetchUser() {
log(' fetchUser 開始')
await new Promise((r) => setTimeout(r, 300))
this.user = { name: 'taro' }
this.checked = true
log(' fetchUser 完了')
},
},
})
const Edit = {
setup() {
onBeforeRouteLeave((to, from) => {
if (globalThis.dirty) { log(' onBeforeRouteLeave: 入力途中なので false を返す'); return false }
})
return () => h('h1', 'Edit')
},
}
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { render: () => h('h1', 'Home') } },
{ path: '/mypage', component: { render: () => h('h1', 'MyPage') }, meta: { requiresAuth: true } },
{ path: '/edit', component: Edit },
],
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.checked) await auth.fetchUser()
if (to.meta.requiresAuth && !auth.user) return '/login'
})
router.afterEach((to, from, failure) => log('afterEach', to.fullPath, failure ? '(中止)' : ''))
const app = createApp({ render: () => h(RouterView) })
app.use(createPinia()); app.use(router)
log('最初の表示 /'); await router.isReady(); app.mount('#app')
log('表示:', document.body.textContent)
log('push /mypage(2回目は問い合わせなし)'); await router.push('/mypage')
log('表示:', document.body.textContent)
log('push /edit'); await router.push('/edit')
globalThis.dirty = true
log('push /(入力途中)'); await router.push('/'); log('現在のURL:', router.currentRoute.value.fullPath)
globalThis.dirty = false
log('push /(保存後)'); await router.push('/'); log('現在のURL:', router.currentRoute.value.fullPath)
実行結果(左端の経過時間は実行のたびに少し変わります):
11ms 最初の表示 /
15ms fetchUser 開始
316ms fetchUser 完了
317ms afterEach /
326ms 表示: Home
326ms push /mypage(2回目は問い合わせなし)
327ms afterEach /mypage
329ms 表示: MyPage
329ms push /edit
329ms afterEach /edit
330ms push /(入力途中)
331ms onBeforeRouteLeave: 入力途中なので false を返す
331ms afterEach / (中止)
331ms 現在のURL: /edit
331ms push /(保存後)
331ms afterEach /
332ms 現在のURL: /
- 最初の移動では fetchUser の完了を待ってから afterEach が呼ばれ、その後で画面が出ています。
- 2回目の
/mypageへの移動は、checked が true になっているため問い合わせをせず、すぐ完了しています。「確認済みかどうか」のフラグを持つと、移動のたびに通信する形を避けられます。 /editでは、dirty が true の間は onBeforeRouteLeave が false を返し、URL が/editのままになっています。
RouteMeta の型を拡張する(TypeScript)
TypeScript では、何もしないと to.meta.title の型は unknown です。次のファイルを型チェックします。
npm i -D typescript
// before/router.ts
import { createRouter, createWebHistory } from 'vue-router'
const Page = { render: () => null }
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Page, meta: { title: 'ホーム' } },
{ path: '/mypage', component: Page, meta: { title: 'マイページ', requiresAuth: 'yes' } },
{ path: '/admin', component: Page, meta: { title: '管理', requiresAuth: true, roles: ['admin'] } },
],
})
router.beforeEach((to) => {
const title: string = to.meta.title
const roles: string[] = to.meta.roles ?? []
if (to.meta.requireAuth) return '/login'
document.title = title + roles.join(',')
})
tsconfig.json は strict を true、moduleResolution を bundler、noEmit を true にしています。
npx tsc -p before --pretty false
before/router.ts(15,9): error TS2322: Type 'unknown' is not assignable to type 'string'.
before/router.ts(16,9): error TS2740: Type '{}' is missing the following properties from type 'string[]': length, pop, push, concat, and 29 more.
to.meta.title も to.meta.roles も型がわからないため、代入の行でエラーになります。一方で、9行目の requiresAuth: 'yes' という値の書き間違いは見つかっていません。
そこで、公式ドキュメント(Route Meta Fields の TypeScript の節)にある方法で、RouteMeta インターフェースを拡張します。同じ router.ts を after フォルダにコピーし、型定義ファイルを1つ足します。
// after/route-meta.d.ts
import 'vue-router'
export {}
declare module 'vue-router' {
interface RouteMeta {
title: string
requiresAuth?: boolean
roles?: string[]
}
}
npx tsc -p after --pretty false
after/router.ts(9,65): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.
エラーが入れ替わりました。代入の2か所は型が付いて通るようになり、代わりに requiresAuth: 'yes' が「string は boolean に代入できない」と指摘されています。 ここを requiresAuth: true に直すと、tsc は何も出力せず終了コード0で終わりました。
1点、覚えておきたい動きがあります。router.ts の17行目は to.meta.requireAuth(s が抜けた書き間違い)ですが、型を拡張した後もエラーになっていません。RouteMeta は任意のキーを受け付ける定義になっていて、宣言していないキーは unknown として扱われるためです。キー名の書き間違いは型チェックでは見つからないので、meta のキーは定数やヘルパー関数にまとめておくと安心です。
どのガードを使うかの判断表
| やりたいこと | 使うガード | 理由 |
|---|---|---|
| 未ログインならログイン画面へ送る | router.beforeEach |
全ルートに1か所で効き、最初のほうで判定できる |
| 特定のルートだけパラメータを検査する | ルート定義の beforeEnter |
ほかのルートに影響しない |
| 入力途中でページを離れるのを止める | onBeforeRouteLeave |
そのコンポーネントの状態を見て判定できる |
/users/1 → /users/2 でデータを取り直す |
onBeforeRouteUpdate |
コンポーネントが再利用され、mounted が呼ばれないため |
| すべてのガード通過後に処理をしたい | router.beforeResolve |
コンポーネント内ガードと非同期読み込みの後に呼ばれる |
| ページタイトルを変える・アクセスを記録する | router.afterEach |
確定後に呼ばれる。failure で中止も見分けられる |
beforeEnter は、実行結果で見たとおり同じルートのパラメータだけが変わるときには呼ばれません。 パラメータが変わるたびに検査したい条件は、beforeEach か onBeforeRouteUpdate に書きます。
つまずきやすい点のチェックリスト
| 症状 | 原因 | 直し方 |
|---|---|---|
Infinite redirect in navigation guard が出る |
リダイレクト先でも同じ条件でリダイレクトしている | 行き先がログイン画面のときは通す。meta で対象を絞る |
| 画面が切り替わらず、エラーも出ない | next を引数に取ったのに呼ばない分岐がある | next をやめて return で書く |
The "next" callback was called more than once の警告 |
next の後に return が無い | return で書く。next を使うなら return next(...) |
"getActivePinia()" was called but there was no active Pinia |
ガードの外(モジュール直下)でストアを取得している | useAuthStore() をガードの中で呼ぶ |
| パラメータを変えても beforeEnter が動かない | 同じルート内の移動では呼ばれない | beforeEach か onBeforeRouteUpdate に移す |
よくある質問
Q. beforeEach と beforeResolve はどう使い分けますか? A. beforeEach は移動の最初のほうで呼ばれ、beforeResolve はコンポーネント内ガードと非同期コンポーネントの読み込みが終わった後、確定の直前に呼ばれます。認証のように早く判定したいものは beforeEach、すべての条件を通過した移動だけに行いたい処理は beforeResolve が合います。
Q. afterEach でリダイレクトできますか? A. afterEach は移動が確定した後に呼ばれるフックで、戻り値で移動を変えることはできません。ページタイトルの変更や記録に使います。第3引数の failure を見ると、中止された移動かどうかがわかります。
まとめ
- 認証の確認は
router.beforeEachに書く。 ルート単位・コンポーネント内のガードより前に判定できる。 - 実行順は beforeEach → beforeEnter → beforeRouteEnter → beforeResolve → afterEach。 離れるページの beforeRouteLeave は beforeEach より前、mounted は afterEach より後。
- 戻り値は「何も返さない=通す」「false=中止」「パスやオブジェクト=リダイレクト」。 next ではなく return で書く。
meta: { requiresAuth: true }は親ルートに書けば子に引き継がれる。 判定はto.meta.requiresAuthで足りる。- redirect クエリには
to.fullPathを入れ、戻すときはアプリ内のパスだけ通す。 - 無限リダイレクトは、リダイレクト先のルートを条件から外すと直る。
- RouteMeta を拡張すると、meta の値の型違いに気づける。 キー名の書き間違いは検出されないので定数にまとめる。
参考・出典
- Vue Router 公式ドキュメント「Navigation Guards」 https://router.vuejs.org/guide/advanced/navigation-guards.html
- Vue Router 公式ドキュメント「Route Meta Fields」 https://router.vuejs.org/guide/advanced/meta.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue Routerナビゲーションガード|beforeEachで認証リダイレクト」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。