Middleware & Auth
TL;DR
Route middleware intercepts navigation and can redirect, block, or pass through. Three types: global (every route, .global.ts suffix), named (reusable files applied via definePageMeta), and inline (defined directly in a page). Middleware runs on both server and client. Combined with auth state (cookies, tokens), middleware is the standard pattern for protecting routes.
Mental Model
The execution flow on every navigation:
Navigation triggered
β Global middleware (alphabetical by filename)
β Named middleware (in definePageMeta order)
β Inline middleware
β Page renders (if no redirect/abort happened)
Each middleware function receives to and from route objects. It can:
- Return nothing β allow navigation to continue
- Return
navigateTo(path)β redirect to a different route - Return
abortNavigation()β cancel navigation entirely - Return
abortNavigation(error)β cancel and show error page
The first middleware that returns a redirect or abort wins. Remaining middleware doesnβt execute.
Route Middleware Types
1. Global middleware β runs on every route change, automatically:
// middleware/01.log.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
console.log(`[nav] ${from.path} β ${to.path}`)
})
The .global.ts suffix is the trigger. Without it, itβs a named middleware. Prefix with numbers to control execution order (01.log.global.ts runs before 02.auth.global.ts).
2. Named middleware β reusable, applied per-page:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
Applied in the page:
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
// or multiple: middleware: ['auth', 'admin']
})
</script>
3. Inline middleware β defined directly in definePageMeta:
<script setup lang="ts">
definePageMeta({
middleware: [
function (to, from) {
// One-off logic for this page only
if (to.query.preview !== 'true') {
return abortNavigation()
}
},
],
})
</script>
Use inline for one-off checks that donβt warrant their own file.
Execution Order
01.analytics.global.ts β alphabetical global first
02.auth.global.ts
middleware: ['role-check', 'subscription'] β named in array order
inline function β inline last
Global middleware sorts alphabetically by filename. Named middleware runs in the order listed in definePageMeta. Inline runs last. Use numeric prefixes on globals to make order explicit.
defineNuxtRouteMiddleware
The wrapper function provides type safety and the correct interface:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
// `to` β the target route (RouteLocationNormalized)
// `from` β the current route (RouteLocationNormalized)
// Access route meta
const requiresAuth = to.meta.requiresAuth
// Access query params
const redirectPath = to.query.redirect as string
// Check auth (your logic)
const token = useCookie('auth-token')
if (!token.value) {
return navigateTo('/login')
}
// Return nothing to allow navigation
})
The Auth Guard Pattern
The most common middleware pattern. Hereβs the full implementation:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('auth-token')
if (!token.value) {
// Not authenticated β redirect to login
// Preserve the intended destination
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
// middleware/guest.ts β opposite: redirect AWAY from login if already auth'd
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('auth-token')
if (token.value) {
// Already authenticated β don't show login page
return navigateTo(to.query.redirect as string || '/dashboard')
}
})
Apply to pages:
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
<!-- pages/login.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'guest' })
</script>
Redirect-after-login pattern:
<!-- pages/login.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'guest' })
const route = useRoute()
const token = useCookie('auth-token')
const login = async (credentials: { email: string; password: string }) => {
const { token: newToken } = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
})
token.value = newToken
// Redirect to intended destination or default
const redirect = route.query.redirect as string
navigateTo(redirect || '/dashboard')
}
</script>
Role-Based Access
Layer middleware for granular access control:
// middleware/admin.ts
export default defineNuxtRouteMiddleware(async () => {
const { data: user } = await useFetch('/api/auth/me')
if (user.value?.role !== 'admin') {
return abortNavigation(
createError({ statusCode: 403, statusMessage: 'Forbidden' })
)
}
})
<!-- pages/admin/users.vue -->
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'admin'], // auth runs first, then admin
})
</script>
Redirect Patterns
// Simple redirect
return navigateTo('/login')
// With query params
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
// Replace history (no back button)
return navigateTo('/login', { replace: true })
// External URL
return navigateTo('https://auth.example.com/login', { external: true })
// With status code (SSR only β affects HTTP response)
return navigateTo('/new-page', { redirectCode: 301 })
// Abort with custom error
return abortNavigation(createError({
statusCode: 403,
statusMessage: 'You do not have access to this page',
}))
// Abort silently (stay on current page)
return abortNavigation()
Server Middleware vs Route Middleware
These are completely different systems that happen to share the word βmiddlewareβ:
| Β | Server Middleware | Route Middleware |
|---|---|---|
| Location | server/middleware/ | middleware/ |
| Runs on | Nitro (H3 events) | Vue Router (navigation) |
| Triggers on | Every HTTP request | Client-side navigation + SSR page load |
| Has access to | H3 event, headers, cookies | Route objects, Vue composables |
| Use for | CORS, API auth, logging | Page guards, auth redirects |
| Receives | event (H3Event) | to, from (RouteLocation) |
Server middleware protects your API endpoints. Route middleware protects your pages. For a typical auth system, you need both:
// server/middleware/api-auth.ts β protects API routes
export default defineEventHandler((event) => {
const url = getRequestURL(event).pathname
if (!url.startsWith('/api/protected')) return
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
event.context.user = verifyToken(token)
})
// middleware/auth.ts β protects pages (user-facing redirects)
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('auth-token')
if (!token.value) {
return navigateTo('/login')
}
})
Using Cookies for Auth State
useCookie is the SSR-safe way to read/write cookies in Nuxt. It works on both server and client:
// composables/useAuth.ts
export function useAuth() {
const token = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 7, // 7 days
secure: true,
httpOnly: false, // Must be false if you read it client-side
sameSite: 'lax',
})
const isAuthenticated = computed(() => !!token.value)
async function login(credentials: LoginCredentials) {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
})
token.value = response.token
}
function logout() {
token.value = null
navigateTo('/login')
}
return { token, isAuthenticated, login, logout }
}
Why cookies over localStorage? Cookies are sent with every request β the server (SSR and API) can read them without additional JavaScript. LocalStorage only exists on the client. With cookies:
- SSR middleware can check auth before rendering
- Server API routes can verify tokens
- No flash of unauthenticated content
Global Auth with Selective Exclusion
Instead of adding middleware: 'auth' to every page, make auth global and opt-out for public pages:
// middleware/01.auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
// Skip public routes
if (to.meta.public) return
const token = useCookie('auth-token')
if (!token.value) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
<!-- pages/login.vue β public page, skip auth -->
<script setup lang="ts">
definePageMeta({
public: true, // Custom meta flag read by global middleware
})
</script>
<!-- pages/index.vue β public landing page -->
<script setup lang="ts">
definePageMeta({ public: true })
</script>
<!-- pages/dashboard.vue β protected (no meta needed, default) -->
<script setup lang="ts">
// No definePageMeta needed β auth applies globally
</script>
This inverts the pattern: everything is protected by default, public pages opt out. Safer for apps where most pages are authenticated.
Walkthrough
- Create a global logging middleware:
// middleware/00.log.global.ts export default defineNuxtRouteMiddleware((to, from) => { console.log(`[route] ${from.path} β ${to.path}`) })Navigate around. Check browser console.
- Create an auth middleware:
// middleware/auth.ts export default defineNuxtRouteMiddleware((to) => { const token = useCookie('auth-token') if (!token.value) { return navigateTo({ path: '/login', query: { redirect: to.fullPath } }) } }) - Create a guest middleware:
// middleware/guest.ts export default defineNuxtRouteMiddleware(() => { const token = useCookie('auth-token') if (token.value) return navigateTo('/dashboard') }) - Wire up pages:
/loginwithmiddleware: 'guest'/dashboardwithmiddleware: 'auth'
-
Implement login: Set cookie β redirect to
query.redirector/dashboard. - Test the flow: Visit
/dashboardunauthenticated β redirected to/login?redirect=/dashboardβ login β redirected back to/dashboard.
Gotchas
- Must
return navigateTo(). Just callingnavigateTo('/login')withoutreturndoesnβt work β the middleware continues executing and the page renders. Always return it. - Middleware runs on BOTH server and client. During SSR, it runs on the server. On client navigation, it runs on the client. Donβt use
window,document, or browser-only APIs withoutimport.meta.clientguard. - Infinite redirect loops. If your auth middleware redirects to
/login, and/loginalso has auth middleware (or a global middleware that catches it), you get an infinite loop. Always exclude the login page from auth checks. - Global middleware filename matters. Must end in
.global.ts.middleware/auth.tsis named (opt-in).middleware/auth.global.tsis global (runs always). - Middleware doesnβt run on error pages. If your app shows
error.vue, middleware is bypassed. Donβt rely on middleware for security on error pages. definePageMetais a compiler macro. You canβt conditionally apply middleware with runtime logic insidedefinePageMeta. Use the global middleware + meta flag pattern instead.- Order with global middleware. Alphabetical by filename.
auth.global.tsruns beforelogging.global.ts. Prefix with numbers:01.auth.global.ts,02.logging.global.ts. abortNavigation()without arguments stays on current page. The user sees no error, just nothing happens. Provide an error for feedback:abortNavigation(createError({ statusCode: 403 })).useCookieduring middleware is fine. It works server and client. But the cookie must be accessible to JavaScript (nothttpOnly: true) if you need to read it client-side.- Middleware runs on every navigation β keep it fast. Donβt make API calls in middleware unless absolutely necessary. Read cookies, check local state, redirect. Heavy validation belongs in the page or a server endpoint.
Exercise
Implement a complete auth flow:
- Server API:
POST /api/auth/loginβ accepts email/password, returns token, sets cookieGET /api/auth/meβ returns current user from token (or 401)POST /api/auth/logoutβ clears cookie
- Middleware:
auth.tsβ redirects to login if no token (preserves redirect path)guest.tsβ redirects to dashboard if already authenticatedadmin.tsβ checks user role, shows 403 if not admin
- Pages:
/loginβ login form, guest middleware, redirect-back-after-login/registerβ registration form, guest middleware/dashboardβ protected, shows user info/adminβ protected with['auth', 'admin']middleware stack
- Composable:
useAuth()β wraps cookie, exposesisAuthenticated,login(),logout()
- Test: Full cycle β unauthenticated visit to
/adminβ redirect to/login?redirect=/adminβ login β redirect to/adminβ shows 403 (not admin role) or content (admin role).