The Authentication Flow
Where a Vue session lives, a router guard that waits for it, and the redirect flash that happens when it does not.
router.beforeEach(async (to) => { /* return true, false, or a route location */ })Authentication in a single-page application is three questions: where the session is held, how the first page load learns about it, and what the user sees while that is being decided.
Where the session lives
Keep the access token and the current user in a Pinia store. A store is a module of shared reactive state, from Stage 3's Pinia lessons. Memory is the right home for a token, because a reload is meant to re-establish the session rather than trust whatever is in storage. The long-lived refresh token belongs in an HTTP-only cookie the browser sends and your code cannot read.
A guard that waits
On a reload the store starts empty, so a guard that checks auth.user immediately sends a signed-in user to the login screen. Give the store a ready flag and one restore() call, and have the guard await it. A guard may be an async function; Vue Router holds the navigation until it settles. Returning a route location redirects, exactly as router.push would:
return { name: 'login' } // current form
next({ name: 'login' }) // the older callback form, still supported
The flash of the wrong screen
Mount the app before the first navigation resolves and the user sees the login form for a moment, then the dashboard. Mount inside router.isReady().then(...), which resolves after the initial navigation and its guards have finished. The example is the login view: it submits, then replaces the current history entry with the page the guard had asked for.
Example
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const pending = ref(false)
async function submit() {
pending.value = true
error.value = null
try {
await auth.login(email.value, password.value)
const redirect = route.query.redirect
await router.replace(typeof redirect === 'string' ? redirect : { name: 'dashboard' })
} catch {
error.value = 'Those details were not accepted.'
} finally {
pending.value = false
}
}
</script>
<template>
<form @submit.prevent="submit">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="username" />
<label for="password">Password</label>
<input id="password" v-model="password" type="password" autocomplete="current-password" />
<p v-if="error" role="alert">{{ error }}</p>
<button type="submit" :disabled="pending">Sign in</button>
</form>
</template>
<style scoped>
form {
display: grid;
gap: 0.5rem;
max-width: 20rem;
}
</style>When to use it
- Keeping a signed-in user on the page they bookmarked after a hard refresh.
- Sending someone back to the page they wanted once they have signed in.
- Clearing a session from one place so every component stops showing private data.
More examples
The session store
A Pinia 4 setup store: refs become state, computed values become getters, functions become actions, and every state ref must appear in the returned object or Pinia will not treat it as state. ready separates 'no session' from 'not asked yet', which is the distinction the guard needs.
// src/stores/auth.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface User {
id: string
email: string
}
interface SessionResponse {
user: User
accessToken: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const accessToken = ref<string | null>(null)
const ready = ref(false)
const isAuthenticated = computed(() => user.value !== null)
async function restore() {
try {
const response = await fetch('/api/session', { credentials: 'include' })
if (response.ok) {
const data = (await response.json()) as SessionResponse
user.value = data.user
accessToken.value = data.accessToken
}
} finally {
ready.value = true
}
}
async function login(email: string, password: string) {
const response = await fetch('/api/session', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (!response.ok) throw new Error('Sign in failed')
const data = (await response.json()) as SessionResponse
user.value = data.user
accessToken.value = data.accessToken
ready.value = true
}
async function logout() {
await fetch('/api/session', { method: 'DELETE', credentials: 'include' })
user.value = null
accessToken.value = null
}
return { user, accessToken, ready, isAuthenticated, restore, login, logout }
})The guard, and a typed meta field
The module augmentation gives route meta real types, so to.meta.requiresAuth is checked rather than guessed. The guard awaits restore() once per page load, then either lets the navigation through, or returns a location carrying the requested path as a redirect query.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
}
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue')
},
{
path: '/',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true }
}
]
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.ready) {
await auth.restore()
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
})
export default routerMounting after the first navigation
router.isReady() returns a Promise<void> that resolves once the initial navigation, including its async guards, has completed. Mounting inside it removes the flash of the login screen on reload. Pinia is installed before the router because the guard uses a store.
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
router.isReady().then(() => {
app.mount('#app')
})
Discussion