What is middleware in Nuxt and how to use it?
Nuxt middleware is a function that runs before a page component mounts, letting you check conditions, redirect users, or block navigation entirely.
Theory
TL;DR
- Think of it like airport security: checks your ticket (route), lets you through, sends you to check-in (login), or stops you cold
- Three types: global (every route), named (opt-in per page), inline (one-off inside
definePageMeta) - Returns
navigateTo()to redirect orabortNavigation()to stop without changing the URL - Use it for auth and route guards; skip it for data fetching (that is what
useAsyncDatais for) - Global middleware runs in alphabetical order by filename, before named, before inline
Quick example
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('auth').value;
if (!token && to.path !== '/login') {
return navigateTo('/login'); // redirect unauthenticated users
}
// no return = proceed normally
});Any unauthenticated user hitting /dashboard gets sent to /login. Authenticated users pass through. The .global.ts suffix means it runs on every route automatically, no extra configuration needed.
Key difference from Vue composables
Middleware intercepts navigation at the router level, before the page component mounts. A useFetch or onMounted call runs after. That timing gap is what lets middleware block rendering entirely and prevent flashes of private content. If you put an auth check in onMounted, the page renders first, then redirects. I have watched teams debug "flashing dashboard" issues for days before tracing it back to this exact timing difference. Bad UX, worse security.
Types of middleware
Global middleware lives in middleware/ with the .global.ts suffix. Nuxt picks it up automatically and runs it on every route change. Alphabetical order among globals determines sequence.
Named middleware also lives in middleware/ but without the suffix. You attach it to specific pages via definePageMeta:
// pages/admin/users.vue
definePageMeta({
middleware: 'admin' // runs middleware/admin.ts
});Inline middleware is a function defined directly inside definePageMeta. Good for one-off checks that do not need reuse:
definePageMeta({
middleware: (to, from) => {
if (!hasFeatureFlag('beta')) {
return navigateTo('/');
}
}
});When to use
- Auth checks and session validation: global or named middleware with
useCookie()or a Pinia store - Role-based access (admin-only pages): named middleware attached via
definePageMeta - One-time feature flags or experiment gates: inline middleware
- SEO redirects (canonical URLs, legacy paths): global middleware
Skip middleware for data loading. useAsyncData and useFetch handle that. Skip it for UI logic too. That belongs in onMounted or composables.
How it works internally
When a route change fires, Nuxt collects all middleware functions in order: globals first (alphabetical by filename), then named from the page meta, then inline. They run one by one. The first function that returns something other than null or undefined stops the chain. On the server (SSR), Nitro runs the same middleware before render:page. On the client, Vue Router's beforeEach hook handles it. So on initial load, middleware runs twice: once on the server, once when the client hydrates. That is why localStorage does not work in middleware. It does not exist on the server.
Common mistakes
1. Mutating to.path directly
// wrong
to.path = '/login'; // does nothingto is a readonly RouteLocation object. Vue Router ignores direct mutations. Use return navigateTo('/login') instead.
2. Using localStorage for auth checks
// wrong
const token = localStorage.getItem('token'); // undefined on serverSSR has no localStorage. The middleware runs on the server, finds nothing, and redirects. Then on the client it finds the token and passes. Inconsistent behavior, hydration mismatch. Use useCookie() or useState(). Both work on server and client.
3. Losing redirect history
// wrong - user cannot return after login
return navigateTo('/login');
// right
return navigateTo({ path: '/login', query: { redirect: from.path } });Pass from.path as a query parameter. After login, read it and send the user where they were going.
4. Global middleware blocking Nuxt DevTools
A global auth check also intercepts internal Nuxt paths like /__nuxt_devtools__. Add an early return:
if (to.path.startsWith('/__')) return; // skip internal paths5. Async without await
// wrong - chain continues before fetch resolves
fetch('/api/user').then(user => { /* ... */ });
// right
const user = await $fetch('/api/user');
if (!user) return navigateTo('/login');Without await, the middleware returns undefined immediately and the next function in the chain fires before your async check finishes.
Real-world usage
nuxt-authmodule: wraps session check inauth.global.tsusing theuseAuth()composable- A/B testing: named middleware assigns the user to a variant and redirects to the matching page
- Stripe checkout: inline middleware checks session before showing the payment page
- Legacy URL redirects: global middleware maps old paths to new ones before any page renders
Follow-up questions
Q: What is the difference between navigateTo() and abortNavigation()?
A: navigateTo() changes the URL and renders a different page. abortNavigation() stops the current navigation without changing the URL. You can pass an error object to abortNavigation() to render Nuxt's error page with a custom message.
Q: In what order does middleware run?
A: Global middleware runs first, in alphabetical order by filename. Then named middleware in the order listed in definePageMeta. Then inline. The chain stops at the first non-null return value.
Q: Why does my middleware run twice on initial load?
A: With SSR, middleware runs once on the server and once on the client during hydration. On subsequent client-side navigations it only runs in the browser. Use process.server or process.client guards if you need to limit execution to one environment.
Q: Can middleware access data fetched by useAsyncData?
A: No. Middleware runs before useAsyncData resolves. For shared data, use useState() or a Pinia store populated in a server plugin or earlier in the request lifecycle.
Q: How would you build cache-aware middleware that skips repeated auth checks on recent visits?
A: Store a short-lived cookie after successful validation: useCookie('lastCheck', { maxAge: 300 }). On the next request, if that cookie exists and the session cookie is also present, skip the API call and return. Both cookies sync between server and client, so SSR stays consistent. This is the pattern to reach for on high-traffic routes where hitting the auth API on every page load is too expensive.
Examples
Basic: global auth guard
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('auth').value;
if (!token && to.path !== '/login') {
// preserve the intended destination
return navigateTo({
path: '/login',
query: { redirect: to.path }
});
}
});Every protected page redirects unauthenticated users to /login?redirect=/dashboard. After login, read route.query.redirect and send them where they were going. Without the query part, users land on the home page every time.
Intermediate: role-based access in a SaaS dashboard
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to) => {
const { user } = useUserStore(); // Pinia store
if (!user.loggedIn) {
return navigateTo('/login');
}
if (user.role !== 'admin' && to.path.startsWith('/admin')) {
abortNavigation('Admin access required'); // renders error page
}
});
// pages/admin/users.vue
definePageMeta({
middleware: 'admin'
});An admin gets the page. A logged-in non-admin sees the error page. A guest goes to /login. Three outcomes, one middleware file, zero duplicated checks across admin pages.
Advanced: chaining global middleware with async operations
// middleware/auth.global.ts (runs first - 'a' before 'l' alphabetically)
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('token').value;
if (!token) {
// 401 error page renders, URL does not change, no content flash
throw createError({ statusCode: 401, message: 'Unauthorized' });
}
});
// middleware/logger.global.ts (runs only if auth passed)
export default defineNuxtRouteMiddleware(async (to) => {
await $fetch('/api/log', { method: 'POST', body: { path: to.path } });
});Visiting a protected route without a token: auth throws 401, logger never runs. With a valid token: auth passes, logger records the visit. The await in logger delays rendering until the log request completes. If that is a performance concern, remove await and accept the trade-off that some log entries may be missed on fast navigations.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.